[main]: front start

This commit is contained in:
it-is-not-alright 2024-09-24 20:57:08 +04:00
parent 7dc9d18ccd
commit cbfbb036ee
76 changed files with 19116 additions and 0 deletions

6
front/.babelrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-typescript",
"@babel/preset-react"
]
}

1
front/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

6
front/.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "auto"
}

BIN
front/README copy.md Normal file

Binary file not shown.

4
front/custom.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
export default content;
}

19
front/eslint.config.js Normal file
View File

@ -0,0 +1,19 @@
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended,
{
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
},
},
);

17990
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
front/package.json Normal file
View File

@ -0,0 +1,66 @@
{
"name": "template",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"build": "webpack --mode production",
"build-storybook": "storybook build",
"lint": "eslint src/**/*.{js,ts,tsx}",
"lint-fix": "npm run lint -- --fix",
"start": "webpack serve --mode development",
"storybook": "storybook dev -p 6006",
"style-lint": "npx stylelint '**/*.{css,scss}'",
"style-lint-fix": "npm run style-lint -- --fix",
"test": "jest"
},
"dependencies": {
"@svgr/webpack": "^8.1.0",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "6.26.0",
"zustand": "5.0.0-rc.2"
},
"devDependencies": {
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@chromatic-com/storybook": "^1.6.1",
"@eslint/js": "^9.9.0",
"@storybook/addon-essentials": "^8.2.9",
"@storybook/addon-interactions": "^8.2.9",
"@storybook/addon-links": "^8.2.9",
"@storybook/addon-onboarding": "^8.2.9",
"@storybook/addon-webpack5-compiler-swc": "^1.0.5",
"@storybook/blocks": "^8.2.9",
"@storybook/react": "^8.2.9",
"@storybook/react-webpack5": "^8.2.9",
"@storybook/test": "^8.2.9",
"@types/eslint__js": "^8.42.3",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"html-webpack-plugin": "^5.5.0",
"lint-staged": "^15.2.9",
"prettier": "^3.3.3",
"sass": "^1.77.8",
"sass-loader": "^16.0.0",
"storybook": "^8.2.9",
"style-loader": "^4.0.0",
"stylelint": "^16.8.1",
"stylelint-config-standard-scss": "^13.1.0",
"ts-jest": "^29.2.4",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.1.0",
"typescript-plugin-css-modules": "^5.1.0",
"webpack": "^5.93.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,20 @@
@font-face {
font-family: Rubik;
font-style: normal;
font-weight: 400;
src: url('./regular.woff2') format('woff2');
}
@font-face {
font-family: Rubik;
font-style: normal;
font-weight: 500;
src: url('./medium.woff2') format('woff2');
}
@font-face {
font-family: Rubik;
font-style: normal;
font-weight: 700;
src: url('./bold.woff2') format('woff2');
}

View File

@ -0,0 +1 @@
@import "./rubik/styles.css";

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<g>
<path d="M6.5,9C7.88,9,9,7.88,9,6.5c0-0.12-0.01-0.24-0.03-0.35L6.15,8.97C6.26,8.99,6.38,9,6.5,9z"/>
<path d="M5.12,8.58L5.12,8.58l0.73-0.73l0,0l1.99-1.99l0.73-0.73l2.09-2.09L11.8,1.9L11.1,1.2L9.85,2.44
C8.86,1.85,7.71,1.5,6.5,1.5c-2.65,0-5,1.58-6.01,4.04c-0.25,0.61-0.25,1.31,0,1.92c0.41,1,1.05,1.84,1.84,2.51L1.2,11.1L1.9,11.8
l2.6-2.6L5.12,8.58z M5.15,7.14C5.06,6.95,5,6.73,5,6.5C5,5.67,5.67,5,6.5,5c0.23,0,0.45,0.06,0.64,0.15L5.15,7.14z M1.42,7.08
c-0.15-0.37-0.15-0.8,0-1.17C2.27,3.84,4.26,2.5,6.5,2.5c0.94,0,1.83,0.25,2.62,0.68L7.88,4.42C7.48,4.15,7.01,4,6.5,4
C5.12,4,4,5.12,4,6.5c0,0.51,0.15,0.98,0.42,1.38L3.04,9.26C2.34,8.69,1.77,7.96,1.42,7.08z"/>
<path d="M12.51,5.54c-0.28-0.68-0.67-1.27-1.13-1.8l-0.71,0.71c0.37,0.43,0.69,0.92,0.91,1.47c0.15,0.37,0.15,0.79,0,1.17
C10.73,9.16,8.74,10.5,6.5,10.5c-0.56,0-1.1-0.1-1.62-0.26L4.1,11.02c0.75,0.3,1.56,0.48,2.4,0.48c2.65,0,5.01-1.58,6.01-4.04
C12.76,6.85,12.76,6.15,12.51,5.54z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M6.5,11.5c-2.65,0-5-1.58-6.01-4.04c-0.25-0.61-0.25-1.31,0-1.92C1.5,3.09,3.85,1.5,6.5,1.5c2.65,0,5.01,1.58,6.01,4.04
c0.25,0.61,0.25,1.31,0,1.92C11.51,9.91,9.15,11.5,6.5,11.5z M6.5,2.5c-2.24,0-4.23,1.34-5.08,3.42c-0.15,0.37-0.15,0.79,0,1.17
C2.27,9.16,4.26,10.5,6.5,10.5c2.24,0,4.23-1.34,5.08-3.42c0.15-0.37,0.15-0.8,0-1.17C10.73,3.84,8.74,2.5,6.5,2.5z"/>
<path d="M6.5,9C5.12,9,4,7.88,4,6.5S5.12,4,6.5,4S9,5.12,9,6.5S7.88,9,6.5,9z M6.5,5C5.67,5,5,5.67,5,6.5S5.67,8,6.5,8S8,7.33,8,6.5
S7.33,5,6.5,5z"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

11
front/public/index.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,37 @@
@mixin light {
color-scheme: light;
--clr-primary: #363a4e;
--clr-secondary: #bca59f;
--clr-accent: #80845c;
--clr-accent-o50: #80845c80;
--clr-layer-100: #EAECF1;
--clr-layer-200: #DFE1E6;
--clr-layer-300: #FFFFFF;
--clr-text-100: #0f1015;
--clr-shadow: #363a4e1A;
}
@mixin dark {
color-scheme: dark;
--clr-primary: #b1b5c9;
--clr-secondary: #604943;
--clr-accent: #9fa37b;
--clr-accent-o50: #9fa37b80;
--clr-layer-100: #0E1015;
--clr-layer-200: #191B20;
--clr-layer-300: #2E3139;
--clr-text-100: #eaebf0;
--clr-shadow: transparent;
}

View File

@ -0,0 +1,22 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import { About, Home } from '@components/pages';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route path={'/'} element={<Home />} />
<Route path={'/about'} element={<About />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1,20 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body, h1, h2, h3, h4, p,
figure, blockquote, dl, dd {
margin: 0;
}
img,
svg,
picture {
display: block;
}
input {
font: inherit;
}

View File

@ -0,0 +1,26 @@
@use './reset';
@use './theme' as theme;
html[data-theme='light'] {
@include theme.light;
}
html[data-theme='dark'] {
@include theme.dark;
}
html[data-theme='default'] {
@include theme.light;
@media (prefers-color-scheme:dark) {
@include theme.dark;
}
}
body {
font-family: Rubik, sans-serif;
background-color: var(--clr-layer-100);
}
#root {
height: 100dvh;
}

View File

@ -0,0 +1,3 @@
import MainLayout from './main-layout';
export { MainLayout };

View File

@ -0,0 +1,18 @@
import { Header } from '@components/ux';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
function MainLayout() {
return (
<div className={styles.mainLayout}>
<Header />
<main>
<Outlet />
</main>
</div>
);
}
export default MainLayout;

View File

@ -0,0 +1,8 @@
.mainLayout {
display: grid;
height: 100%;
grid-template:
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}

View File

@ -0,0 +1,15 @@
import { Button } from '@components/ui';
import React from 'react';
import styles from './styles.module.scss';
function About() {
return (
<div className={styles.about}>
<p>About Page</p>
<Button variant="secondary">Button</Button>
</div>
);
}
export default About;

View File

@ -0,0 +1,3 @@
.about {
padding: 10px;
}

View File

@ -0,0 +1,30 @@
import { Button, PasswordTextField, TextField } from '@components/ui';
import React from 'react';
import styles from './styles.module.scss';
function Home() {
return (
<div className={styles.home}>
<div className={styles.box}>
<Button>Button</Button>
<Button variant="secondary">Button</Button>
</div>
<div className={styles.box}>
<Button scale="s">Button</Button>
<Button>Button</Button>
<Button scale="l">Button</Button>
</div>
<div className={styles.box}>
<TextField scale="m" />
<PasswordTextField scale="m" />
</div>
<div className={styles.box}>
<TextField scale="l" />
<PasswordTextField scale="l" />
</div>
</div>
);
}
export default Home;

View File

@ -0,0 +1,12 @@
.home {
padding: 10px;
display: flex;
gap: 10px;
flex-direction: column;
}
.box {
display: flex;
gap: 10px;
align-items: center
}

View File

@ -0,0 +1,4 @@
import About from './about';
import Home from './home/index';
export { About, Home };

View File

@ -0,0 +1,23 @@
import clsx from 'clsx';
import React from 'react';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { ButtonProps } from './types.js';
function Button({
variant = 'primary',
scale = 'm',
className,
...props
}: ButtonProps) {
const classes = clsx(
styles.button,
styles[variant],
styles[scale],
className,
);
return <RawButton className={classes} {...props} />;
}
export default Button;

View File

@ -0,0 +1,39 @@
.button {
border-radius: 5px;
cursor: pointer;
font-weight: 500;
transition: all 0.1s ease-in-out;
&:hover {
filter: brightness(0.9);
}
&:active {
filter: brightness(0.8);
}
}
.primary {
background-color: var(--clr-primary);
color: var(--clr-layer-100);
}
.secondary {
background-color: var(--clr-secondary);
color: var(--clr-text-100);
}
.s {
padding: 8px 16px;
font-size: 12px;
}
.m {
padding: 10px 20px;
font-size: 16px;
}
.l {
padding: 12px 24px;
font-size: 20px;
}

View File

@ -0,0 +1,6 @@
import { RawButtonProps } from '../raw/raw-button/types';
export type ButtonProps = {
variant?: 'primary' | 'secondary';
scale?: 's' | 'm' | 'l';
} & RawButtonProps;

View File

@ -0,0 +1,23 @@
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { ControlLabelProps } from './types';
export default function ControlLabel({
text,
position = 'top',
className,
children,
...props
}: ControlLabelProps) {
const classes = clsx(styles.label, styles[position], className);
const reversed = position === 'right' || position === 'bottom';
return (
<label className={classes} {...props}>
{reversed && children}
{text && <p>{text}</p>}
{!reversed && children}
</label>
);
}

View File

@ -0,0 +1,15 @@
.label {
display: flex;
}
.left,
.right {
gap: 10px;
align-items: center;
}
.top,
.bottom {
gap: 5px;
flex-direction: column;
}

View File

@ -0,0 +1,4 @@
export type ControlLabelProps = {
text?: string;
position?: 'left' | 'top' | 'right' | 'bottom';
} & React.ComponentPropsWithoutRef<'label'>;

View File

@ -0,0 +1,15 @@
import clsx from 'clsx';
import React from 'react';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { IconButtonProps } from './types';
export default function IconButton({
scale = 'm',
className,
...props
}: IconButtonProps) {
const classes = clsx(styles.button, styles[scale], className);
return <RawButton className={classes} {...props} />;
}

View File

@ -0,0 +1,32 @@
.button {
border-radius: 100%;
cursor: pointer;
&:hover {
background-color: var(--clr-accent-o50);
}
svg {
fill: var(--clr-primary);
height: 100%;
width: 100%;
}
}
.s {
padding: 4px;
height: 28px;
width: 28px;
}
.m {
padding: 6px;
height: 36px;
width: 36px;
}
.l {
padding: 8px;
height: 44px;
width: 44px;
}

View File

@ -0,0 +1,5 @@
import { RawButtonProps } from '../raw/raw-button/types';
export type IconButtonProps = {
scale?: 's' | 'm' | 'l';
} & RawButtonProps;

View File

@ -0,0 +1,19 @@
import Button from './button';
import ControlLabel from './control-label';
import IconButton from './icon-button';
import Input from './input';
import Menu from './menu';
import PasswordTextField from './password-text-field';
import Select from './select';
import TextField from './text-field';
export {
Button,
ControlLabel,
IconButton,
Input,
Menu,
PasswordTextField,
Select,
TextField,
};

View File

@ -0,0 +1,44 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import RawInput from '../raw/raw-input';
import styles from './styles.module.scss';
import { InputProps } from './types';
export default function Input({
scale = 'm',
wrapper = {},
leftNode,
rightNode,
className,
...props
}: InputProps) {
const [focus, setFocus] = useState<boolean>(false);
const wrapperClassName = clsx(
styles.wrapper,
focus && styles.wrapperFocus,
wrapper.className,
);
const inputClassName = clsx(styles.input, styles[scale], className);
const handleFocus = () => {
setFocus(true);
};
const handleBlur = () => {
setFocus(false);
};
return (
<div {...wrapper} className={wrapperClassName}>
{leftNode}
<RawInput
className={inputClassName}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
{rightNode}
</div>
);
}

View File

@ -0,0 +1,29 @@
.wrapper {
display: flex;
align-items: center;
border-radius: 5px;
outline: solid 0px var(--clr-accent-o50);
transition: all 0.1s ease-in-out;
background-color: var(--clr-layer-200);
}
.wrapperFocus {
outline-width: 2px;
}
.input {
color: var(--clr-text-100);
outline: none;
}
.m {
padding: 10px 20px;
font-size: 16px;
line-height: 16px;
}
.l {
padding: 12px 24px;
font-size: 20px;
line-height: 20px;
}

View File

@ -0,0 +1,10 @@
import { RawInputProps } from '../raw/raw-input/types';
type InputProps = {
scale?: 'm' | 'l';
wrapper?: React.ComponentPropsWithoutRef<'div'>;
leftNode?: React.ReactNode;
rightNode?: React.ReactNode;
} & RawInputProps;
export { InputProps };

View File

@ -0,0 +1,50 @@
import clsx from 'clsx';
import React from 'react';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { MenuProps } from './types';
export default function Menu<T>({
options,
selected,
getOptionKey,
getOptionLabel,
onSelect,
visible = true,
className,
nodeRef,
...props
}: MenuProps<T>) {
const handleButtonClick = (option: T) => {
onSelect?.(option);
};
const calcButtonClassName = (option: T) => {
const isSelected =
selected && getOptionKey(option) === getOptionKey(selected);
return clsx(
styles.button,
isSelected ? styles.buttonSelected : styles.buttonUnselected,
);
};
if (!visible) {
return null;
}
return (
<ul className={clsx(className, styles.menu)} ref={nodeRef} {...props}>
{options.map((option) => (
<li key={getOptionKey(option)} className={styles.item}>
<RawButton
onClick={() => handleButtonClick(option)}
className={calcButtonClassName(option)}
>
{getOptionLabel(option)}
</RawButton>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,31 @@
.menu {
list-style: none;
margin: 0;
padding: 0;
border-radius: 5px;
background-color: var(--clr-layer-300);
box-shadow: 0px 1px 2px var(--clr-shadow);
display: flex;
flex-direction: column;
align-items: stretch;
padding: 5px 0px;
}
.button {
width: 100%;
display: flex;
cursor: pointer;
padding: 10px 20px;
color: var(--clr-text-100);
}
.buttonSelected {
background-color: var(--clr-accent);
color: var(--clr-layer-100);
}
.buttonUnselected {
&:hover {
background-color: var(--clr-accent-o50);
}
}

View File

@ -0,0 +1,9 @@
export type MenuProps<T> = {
options: T[];
selected?: T;
getOptionKey: (option: T) => string;
getOptionLabel: (option: T) => string;
onSelect?: (option: T) => void;
visible?: boolean;
nodeRef?: React.ForwardedRef<HTMLUListElement>;
} & Omit<React.ComponentPropsWithoutRef<'ul'>, 'onSelect'>;

View File

@ -0,0 +1,35 @@
import HideIcon from '@public/images/svg/hide.svg';
import ShowIcon from '@public/images/svg/show.svg';
import React, { useState } from 'react';
import IconButton from '../icon-button';
import TextField from '../text-field';
import { PasswordTextFieldProps } from './types';
export default function PasswordTextField({
rightNode,
scale,
...props
}: PasswordTextFieldProps) {
const [visible, setVisible] = useState<boolean>(false);
const handleShowButtonClick = () => {
setVisible(!visible);
};
return (
<TextField
type={visible ? 'text' : 'password'}
scale={scale}
rightNode={
<>
<IconButton scale={scale} onClick={handleShowButtonClick}>
{visible ? <HideIcon /> : <ShowIcon />}
</IconButton>
{rightNode}
</>
}
{...props}
/>
);
}

View File

@ -0,0 +1,3 @@
import { TextFieldProps } from '../text-field/types';
export type PasswordTextFieldProps = {} & Omit<TextFieldProps, 'type'>;

View File

@ -0,0 +1,3 @@
import RawButton from './raw-button';
export { RawButton };

View File

@ -0,0 +1,15 @@
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { RawButtonProps } from './types';
export default function RawButton({
type = 'button',
className,
nodeRef,
...props
}: RawButtonProps) {
const classes = clsx(styles.rawButton, className);
return <button className={classes} ref={nodeRef} type={type} {...props} />;
}

View File

@ -0,0 +1,6 @@
.rawButton {
padding: 0;
border: none;
background-color: transparent;
font: inherit;
}

View File

@ -0,0 +1,3 @@
export type RawButtonProps = {
nodeRef?: React.ForwardedRef<HTMLButtonElement>;
} & React.ComponentPropsWithoutRef<'button'>;

View File

@ -0,0 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import styles from './styles.module.scss';
import { RawInputProps } from './types';
export default function RawInput({ className, ...props }: RawInputProps) {
const classes = clsx(styles.rawInput, className);
return <input className={classes} {...props} />;
}

View File

@ -0,0 +1,6 @@
.rawInput {
padding: 0;
border: none;
background-color: transparent;
font: inherit;
}

View File

@ -0,0 +1,5 @@
type RawInputProps = {
nodeRef?: React.ForwardedRef<HTMLInputElement>;
} & React.ComponentPropsWithoutRef<'input'>;
export { RawInputProps };

View File

@ -0,0 +1,49 @@
import { useMissClick } from '@utils/miss-click';
import React, { useRef, useState } from 'react';
import Input from '../input';
import Menu from '../menu';
import { RawButton } from '../raw';
import styles from './styles.module.scss';
import { SelectProps } from './types';
export default function Select<T>({
options,
value,
getOptionKey,
getOptionLabel,
onChange,
}: SelectProps<T>) {
const toggleButtonRef = useRef<HTMLButtonElement>();
const menuRef = useRef<HTMLUListElement>();
const [menuVisible, setMenuVisible] = useState<boolean>(false);
useMissClick([toggleButtonRef, menuRef], () => setMenuVisible(false));
const handleToggleButtonClick = () => {
setMenuVisible(!menuVisible);
};
const handleMenuSelect = (option: T) => {
setMenuVisible(false);
onChange?.(option);
};
return (
<div className={styles.select}>
<RawButton onClick={handleToggleButtonClick} nodeRef={toggleButtonRef}>
<Input value={value && getOptionLabel(value)} readOnly />
</RawButton>
<Menu
options={options}
selected={value}
getOptionKey={getOptionKey}
getOptionLabel={getOptionLabel}
onSelect={handleMenuSelect}
visible={menuVisible}
className={styles.menu}
nodeRef={menuRef}
/>
</div>
);
}

View File

@ -0,0 +1,9 @@
.select {
position: relative;
}
.menu {
position: absolute;
margin-top: 5px;
z-index: 1;
}

View File

@ -0,0 +1,7 @@
export type SelectProps<T> = {
options: T[];
value?: T;
getOptionKey: (option: T) => string;
getOptionLabel: (option: T) => string;
onChange?: (option: T) => void;
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>;

View File

@ -0,0 +1,13 @@
import React from 'react';
import ControlLabel from '../control-label';
import Input from '../input';
import { TextFieldProps } from './types';
export default function TextField({ label = {}, ...props }: TextFieldProps) {
return (
<ControlLabel {...label}>
<Input {...props} />
</ControlLabel>
);
}

View File

@ -0,0 +1,6 @@
import { ControlLabelProps } from '../control-label/types';
import { InputProps } from '../input/types';
export type TextFieldProps = {
label?: ControlLabelProps;
} & InputProps;

View File

@ -0,0 +1,14 @@
import React from 'react';
import ThemeSelect from '../theme-select';
import styles from './styles.module.scss';
function Header() {
return (
<header className={styles.header}>
<ThemeSelect />
</header>
);
}
export default Header;

View File

@ -0,0 +1,5 @@
.header {
display: flex;
gap: 10px;
margin: 10px;
}

View File

@ -0,0 +1,3 @@
import Header from './header';
export { Header };

View File

@ -0,0 +1,22 @@
import { Select } from '@components/ui';
import { useColorThemeStore } from '@store/color-theme';
import { ColorTheme, THEMES } from '@utils/color-theme';
import React from 'react';
export default function ThemeSelect() {
const { theme, setTheme } = useColorThemeStore();
const handleChange = (newTheme: ColorTheme) => {
setTheme(newTheme);
};
return (
<Select
options={THEMES}
value={theme}
getOptionKey={(t) => t.key}
getOptionLabel={(t) => t.name}
onChange={handleChange}
/>
);
}

1
front/src/custom.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.module.scss';

9
front/src/index.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './components/app';
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(<App />);

View File

@ -0,0 +1,3 @@
export const STORAGE_KEYS = {
colorTheme: 'color-theme',
};

View File

@ -0,0 +1,15 @@
import { initializeColorTheme, setColorTheme } from '@utils/color-theme';
import { create } from 'zustand';
import { ColorThemeStore } from './types';
const initialTheme = initializeColorTheme();
export const useColorThemeStore = create<ColorThemeStore>((set) => ({
theme: initialTheme,
setTheme: (theme) =>
set(() => {
setColorTheme(theme);
return { theme };
}),
}));

View File

@ -0,0 +1,6 @@
import { ColorTheme } from '@utils/color-theme';
export type ColorThemeStore = {
theme: ColorTheme;
setTheme: (theme: ColorTheme) => void;
};

View File

@ -0,0 +1 @@
export * from './utils';

View File

@ -0,0 +1,10 @@
export const arrayToObject = <T>(
array: T[],
getItemKey: (item: T) => string,
) => {
const obj: Record<string, T> = {};
array.forEach((item) => {
obj[getItemKey(item)] = item;
});
return obj;
};

View File

@ -0,0 +1,7 @@
import { ColorTheme } from './types';
export const LIGHT_THEME = { key: 'light', name: 'Light' };
export const DARK_THEME = { key: 'dark', name: 'Dark' };
export const DEFAULT_THEME = { key: 'default', name: 'Default' };
export const THEMES: ColorTheme[] = [LIGHT_THEME, DARK_THEME, DEFAULT_THEME];

View File

@ -0,0 +1,3 @@
export * from './constants';
export * from './types';
export * from './utils';

View File

@ -0,0 +1,4 @@
export type ColorTheme = {
key: string;
name: string;
};

View File

@ -0,0 +1,35 @@
import { arrayToObject } from '@utils/array';
import { STORAGE_KEYS } from 'src/storage/constants';
import { DEFAULT_THEME, THEMES } from './constants';
import { ColorTheme } from './types';
const themeMap = arrayToObject(THEMES, (t) => t.key);
export const setHTMLColorTheme = (theme: ColorTheme) => {
document.documentElement.dataset['theme'] = theme.key;
};
export const getColorTheme = (): ColorTheme => {
const storageTheme = localStorage.getItem(STORAGE_KEYS.colorTheme);
if (!storageTheme || !themeMap[storageTheme]) {
return null;
}
return themeMap[storageTheme];
};
export const setColorTheme = (theme: ColorTheme) => {
localStorage.setItem(STORAGE_KEYS.colorTheme, theme.key);
setHTMLColorTheme(theme);
};
export const initializeColorTheme = () => {
const theme = getColorTheme();
if (!theme) {
setColorTheme(DEFAULT_THEME);
return DEFAULT_THEME;
} else {
setHTMLColorTheme(theme);
return theme;
}
};

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react';
export const useMissClick = (
ignore: React.MutableRefObject<HTMLElement>[],
callback: () => void,
) => {
useEffect(() => {
const handler = (event: MouseEvent) => {
const { target } = event;
if (!(target instanceof Element)) {
return;
}
for (let i = 0; i < ignore.length; i += 1) {
if (ignore[i].current?.contains(target)) {
return;
}
}
callback();
};
window.addEventListener('click', handler);
return () => {
window.removeEventListener('click', handler);
};
}, []);
};

21
front/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"jsx": "react",
"target": "ES6",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"paths": {
"@public/*": ["public/*"],
"@components/*": ["src/components/*"],
"@storage/*": ["src/storage/*"],
"@store/*": ["src/store/*"],
"@utils/*": ["src/utils/*"],
},
"plugins": [
{
"name": "typescript-plugin-css-modules"
}
]
}
}

60
front/webpack.config.js Normal file
View File

@ -0,0 +1,60 @@
import HtmlWebpackPlugin from 'html-webpack-plugin';
import path from 'path';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
const __dirname = path.resolve();
const config = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
publicPath: '/',
},
devServer: {
historyApiFallback: true,
static: {
directory: path.join(__dirname, 'build'),
},
port: 5000,
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
use: ['babel-loader'],
},
{
test: /\.module\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: { modules: { namedExport: false } },
},
'sass-loader',
],
},
{
test: /\.(scss|css)$/,
exclude: /\.module\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(svg)$/i,
use: ['@svgr/webpack'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'public', 'index.html'),
}),
],
resolve: {
extensions: ['.js', '.ts', '.tsx'],
plugins: [new TsconfigPathsPlugin({})],
},
};
export default config;