diff --git a/.eslintrc b/.eslintrc index e400467..4b98069 100644 --- a/.eslintrc +++ b/.eslintrc @@ -24,6 +24,11 @@ "prev": ["let", "const"], "next": "*" }, + { + "blankLine": "always", + "prev": "*", + "next": ["let", "const"] + }, { "blankLine": "any", "prev": ["singleline-let", "singleline-const"], diff --git a/global.d.ts b/global.d.ts index aea1364..22d50c9 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,3 +1,3 @@ type Nullable = T | null | undefined -type UnknownRecord = Record +declare module 'ramda' diff --git a/package.json b/package.json index 875dd1e..acca6fe 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,17 @@ "version": "0.1.0", "dependencies": { "@types/qs": "^6.9.7", - "@types/ramda": "^0.27.44", + "@types/react": "^17.0.15", + "@types/react-router-dom": "^5.1.8", "@types/sprintf-js": "^1.1.2", "axios": "^0.21.1", "loglevel": "^1.7.1", + "modern-css-reset": "^1.4.0", "qs": "^6.10.1", "ramda": "^0.27.1", + "react": "^17.0.2", + "react-router-dom": "^5.2.0", + "sass-rem": "^3.0.0", "sprintf-js": "^1.1.2" } } diff --git a/routing/Route.ts b/routing/Route.ts new file mode 100644 index 0000000..9a1bdee --- /dev/null +++ b/routing/Route.ts @@ -0,0 +1,15 @@ +import React from 'react' + +interface Route { + path: string + name?: string + redirect?: string + exact?: boolean + navbar?: boolean + isIndex?: boolean + icon?: React.ComponentType + childRoutes?: Route[] + component?: React.ComponentType +} + +export default Route diff --git a/routing/Router.tsx b/routing/Router.tsx new file mode 100644 index 0000000..ca85189 --- /dev/null +++ b/routing/Router.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom' +import { prepareRoutes } from './config' +import RouteDefinition from './Route' + +const getComponentRoute = (contextPath: string, component: React.ComponentType) => ( + +) + +const renderRouteConfig = ( + Container: React.ComponentType, + routes: RouteDefinition[], + contextPath: string, +): JSX.Element => { + // Resolve route config object in React Router v3. + const children: React.ReactNode[] = [] + + const renderRoute = (item: RouteDefinition, routeContextPath: string) => { + let newContextPath: string + + if (/(^\/)|(^\*)/.test(item.path)) { + newContextPath = item.path + } else { + newContextPath = `${routeContextPath}/${item.path}` + } + + newContextPath = newContextPath.replace(/\/+/g, '/') + + if (item.redirect) { + const route = ( + } + path={newContextPath} + /> + ) + + children.push(route) + } else if (item.component && item.childRoutes) { + const routeConfig = renderRouteConfig(item.component, item.childRoutes, newContextPath) + + children.push(routeConfig) + } else if (item.component) { + const route = getComponentRoute(newContextPath, item.component) + + children.push(route) + } else if (item.childRoutes) { + item.childRoutes.forEach(r => renderRoute(r, newContextPath)) + } + } + + routes.forEach(item => renderRoute(item, contextPath)) + + // Use Switch as the default container by default + if (!Container) { + return ( + + {children as JSX.TChildren[]} + + ) + } + + return ( + + + + {children as JSX.TChildren[]} + + + + ) +} + +interface Props { + routeConfig: RouteDefinition[] + component: React.ComponentType + baseUrlPath: string +} + +const Router: React.FC = (props: Props) => { + const { routeConfig, component, baseUrlPath } = props + + const preparedRoutes = prepareRoutes(routeConfig) + + return renderRouteConfig(component, preparedRoutes, baseUrlPath) +} + +export default Router diff --git a/routing/config.ts b/routing/config.ts new file mode 100644 index 0000000..434be7c --- /dev/null +++ b/routing/config.ts @@ -0,0 +1,33 @@ +import * as R from 'ramda' +import Route from './Route' + +// Handle isIndex property of route config: +// Duplicate it and put it as the first route rule. +const handleIndexRoute = (route: Route) => { + if (!route.childRoutes || !route.childRoutes.length) { + return + } + + const indexRoute = route.childRoutes.find(R.prop('isIndex')) + + if (indexRoute) { + const first = { + ...indexRoute, + path: route.path, + exact: true, + } + + route.childRoutes.unshift(first) + } + + route.childRoutes.forEach(handleIndexRoute) +} + +export const prepareRoutes: (route: Route[]) => Route[] = R.pipe( + R.filter((r: Route): boolean => Boolean( + r.redirect + || r.component + || (r.childRoutes && r.childRoutes.length > 0), + )), + R.forEach(handleIndexRoute), +) diff --git a/styles/breakpoints.scss b/styles/breakpoints.scss new file mode 100644 index 0000000..98634e6 --- /dev/null +++ b/styles/breakpoints.scss @@ -0,0 +1,4 @@ +$mobile-wide-min: 480px !default; +$tablet-min: 887px !default; +$tablet-wide-min: 1287px !default; +$desktop-min: 1288px !default; diff --git a/styles/global.scss b/styles/global.scss new file mode 100644 index 0000000..b6e6f58 --- /dev/null +++ b/styles/global.scss @@ -0,0 +1,49 @@ +$color: black !default; +$link-color: $color !default; +$font-family: default !default; + +html, body, #root { + padding: 0; + margin: 0; + + font-family: $font-family; + color: $color; + + height: 100%; +} + +input, textarea { + font-family: $font-family; + font-weight: 400; +} + +/* Hide spin button for number input */ +input { + /* Chrome, Safari, Edge, Opera */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + &[type=number] { + -moz-appearance: textfield; + } +} + +button { + cursor: pointer; +} + +p { + margin: 0; +} + +*:focus { + outline: none; +} + +a { + color: $link-color; +} diff --git a/styles/mixins.scss b/styles/mixins.scss new file mode 100644 index 0000000..8dcb2db --- /dev/null +++ b/styles/mixins.scss @@ -0,0 +1,79 @@ +@use "breakpoints"; + +@mixin mobile-narrow { + @media screen and (max-width: breakpoints.$mobile-wide-min - 1) { + @content; + } +} + +@mixin mobile { + @media screen and (max-width: breakpoints.$tablet-min - 1) { + @content; + } +} + +@mixin tablet-narrow { + @media screen and (min-width: breakpoints.$tablet-min) and (max-width: breakpoints.$tablet-wide-min - 1) { + @content; + } +} + +@mixin tablet { + @media screen and (min-width: breakpoints.$tablet-min) and (max-width: breakpoints.$desktop-min - 1) { + @content; + } +} + +@mixin desktop { + @media screen and (min-width: breakpoints.$desktop-min) { + @content; + } +} + +@mixin no-desktop { + @media screen and (max-width: breakpoints.$desktop-min - 1) { + @content; + } +} + +@mixin no-mobile { + @media screen and (min-width: breakpoints.$tablet-min) { + @content; + } +} + +@mixin retina { + @media (min-device-pixel-ratio: 1.5), (min-resolution: 192dpi) { + @content; + } +} + +@mixin webp-fallback { + :global(.no-webp) { + @content; + } +} + +@mixin hover { + @media(hover: hover) and (pointer: fine) { + &:hover { + @content; + } + } + + @media (hover: hover), (-ms-high-contrast:none) { + &:hover { + @content; + } + } + + &:active { + @content; + } +} + +@mixin ie11-fallback { + @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + @content; + } +} diff --git a/styles/reset.scss b/styles/reset.scss new file mode 100644 index 0000000..42f3682 --- /dev/null +++ b/styles/reset.scss @@ -0,0 +1 @@ +@use "~modern-css-reset" as *;