Compare commits
29 Commits
test-open-
...
main
Author | SHA1 | Date | |
---|---|---|---|
8adc9b1ea7 | |||
0aafeda77d | |||
93febb40d3 | |||
2d8465fb09 | |||
9ca0430fda | |||
658a351d28 | |||
a60304ca0f | |||
ee6bbdca8b | |||
507115ece6 | |||
4d40a2cacb | |||
6e87595e2f | |||
d387e5c5bf | |||
1fd0f946e6 | |||
|
484f1f205e | ||
|
65b90f9636 | ||
dee27b731b | |||
7cdbd65432 | |||
7db293de96 | |||
61a3002b54 | |||
9c80e863b8 | |||
06810e59d9 | |||
d24a759f92 | |||
bc53f252d6 | |||
17ba979e41 | |||
52eef8cfbf | |||
93f98c25eb | |||
ef20e8e4e3 | |||
fd5c724342 | |||
d435cde55c |
1
.gitignore
vendored
@ -17,7 +17,6 @@ eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
|
1
front/.gitignore
vendored
@ -1 +1,2 @@
|
||||
/node_modules
|
||||
/build
|
||||
|
7
front/.stylelintrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": ["stylelint-scss", "stylelint-order"],
|
||||
"extends": [
|
||||
"stylelint-config-idiomatic-order"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
BIN
front/README.md
@ -16,4 +16,13 @@ export default tseslint.config(
|
||||
'simple-import-sort/exports': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ varsIgnorePattern: '_', argsIgnorePattern: '_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
8780
front/package-lock.json
generated
@ -1,41 +1,28 @@
|
||||
{
|
||||
"name": "template",
|
||||
"name": "app",
|
||||
"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"
|
||||
"style-lint-fix": "npm run style-lint -- --fix"
|
||||
},
|
||||
"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",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"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",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@ -46,15 +33,14 @@
|
||||
"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",
|
||||
"stylelint": "^16.9.0",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-order": "^6.0.4",
|
||||
"stylelint-scss": "^6.7.0",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.1.0",
|
||||
|
7
front/public/images/svg/arrow-down.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
|
||||
<path d="M3,3.5c-0.13,0-0.26-0.05-0.35-0.15l-2-2c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0L3,2.29l1.65-1.65
|
||||
c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71l-2,2C3.26,3.45,3.13,3.5,3,3.5z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 538 B |
7
front/public/images/svg/arrow-left.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
|
||||
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
|
||||
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 548 B |
7
front/public/images/svg/arrow-right.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
|
||||
<path d="M3.51,3.01c0,0.13-0.05,0.26-0.15,0.35l-2,2c-0.2,0.2-0.51,0.2-0.71,0c-0.2-0.2-0.2-0.51,0-0.71l1.64-1.64L0.64,1.36
|
||||
c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l2,2C3.46,2.75,3.51,2.88,3.51,3.01z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 558 B |
7
front/public/images/svg/arrow-up.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
|
||||
<path d="M3.01,0.49c0.13,0,0.26,0.05,0.35,0.15l2,2c0.2,0.2,0.2,0.51,0,0.71c-0.2,0.2-0.51,0.2-0.71,0L3.01,1.7L1.36,3.35
|
||||
c-0.2,0.2-0.51,0.2-0.71,0s-0.2-0.51,0-0.71l2-2C2.75,0.54,2.88,0.49,3.01,0.49z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 556 B |
10
front/public/images/svg/calendar.svg
Normal 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="M9.68,12H3.32C2.04,12,1,10.88,1,9.5v-5C1,3.12,2.04,2,3.32,2h6.36C10.96,2,12,3.12,12,4.5v5C12,10.88,10.96,12,9.68,12z
|
||||
M3.32,3C2.59,3,2,3.67,2,4.5v5C2,10.33,2.59,11,3.32,11h6.36C10.41,11,11,10.33,11,9.5v-5C11,3.67,10.41,3,9.68,3H3.32z"/>
|
||||
<rect x="1" y="5" width="11" height="1"/>
|
||||
<path d="M4.5,4L4.5,4C4.22,4,4,3.78,4,3.5v-2C4,1.22,4.22,1,4.5,1h0C4.78,1,5,1.22,5,1.5v2C5,3.78,4.78,4,4.5,4z"/>
|
||||
<path d="M8.5,4L8.5,4C8.22,4,8,3.78,8,3.5v-2C8,1.22,8.22,1,8.5,1h0C8.78,1,9,1.22,9,1.5v2C9,3.78,8.78,4,8.5,4z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 877 B |
7
front/public/images/svg/check.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?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 6 4" style="enable-background:new 0 0 6 4;" xml:space="preserve">
|
||||
<path d="M2.25,4C2.12,4,1.99,3.95,1.9,3.85l-1.5-1.5c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l1.15,1.15L4.9,0.15
|
||||
c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71l-3,3C2.51,3.95,2.38,4,2.25,4z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 542 B |
8
front/public/images/svg/close.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?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 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
|
||||
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
|
||||
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
|
||||
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 646 B |
12
front/public/images/svg/delete.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?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="M11.5,3H11H9V2.5C9,1.67,8.33,1,7.5,1h-2C4.67,1,4,1.67,4,2.5V3H2H1.5C1.22,3,1,3.22,1,3.5S1.22,4,1.5,4H2v5.5
|
||||
C2,10.88,3.12,12,4.5,12h4c1.38,0,2.5-1.12,2.5-2.5V4h0.5C11.78,4,12,3.78,12,3.5S11.78,3,11.5,3z M5,2.5C5,2.22,5.22,2,5.5,2h2
|
||||
C7.78,2,8,2.22,8,2.5V3H5V2.5z M10,9.5c0,0.83-0.67,1.5-1.5,1.5h-4C3.67,11,3,10.33,3,9.5V4h7V9.5z"/>
|
||||
<path d="M5.25,9.5c0.28,0,0.5-0.22,0.5-0.5V6c0-0.28-0.22-0.5-0.5-0.5S4.75,5.72,4.75,6v3C4.75,9.28,4.97,9.5,5.25,9.5z"/>
|
||||
<path d="M7.75,9.5c0.28,0,0.5-0.22,0.5-0.5V6c0-0.28-0.22-0.5-0.5-0.5S7.25,5.72,7.25,6v3C7.25,9.28,7.47,9.5,7.75,9.5z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 959 B |
@ -2,15 +2,17 @@
|
||||
<!-- 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="M1.55,11.45L1.55,11.45c-0.2-0.2-0.2-0.51,0-0.71l9.19-9.19c0.2-0.2,0.51-0.2,0.71,0l0,0c0.2,0.2,0.2,0.51,0,0.71
|
||||
l-9.19,9.19C2.06,11.65,1.75,11.65,1.55,11.45z"/>
|
||||
<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
|
||||
<path d="M12.51,5.54c-0.28-0.67-0.66-1.28-1.12-1.8l-0.71,0.71c0.37,0.44,0.68,0.93,0.9,1.48c0.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.11-0.09-1.63-0.25L4.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"/>
|
||||
<path d="M1.42,7.08c-0.15-0.37-0.15-0.8,0-1.17C2.27,3.84,4.26,2.5,6.5,2.5c1.3,0,2.5,0.46,3.46,1.24l0.71-0.71
|
||||
C9.53,2.07,8.07,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.54,1.33,1.49,2.39,2.66,3.09l0.73-0.73
|
||||
C2.8,9.23,1.91,8.29,1.42,7.08z"/>
|
||||
<path d="M6.5,9C7.88,9,9,7.88,9,6.5c0-0.12-0.02-0.23-0.03-0.34L6.16,8.97C6.27,8.98,6.38,9,6.5,9z"/>
|
||||
<path d="M6.5,4C5.12,4,4,5.12,4,6.5c0,0.87,0.45,1.64,1.12,2.08l0.73-0.73C5.35,7.61,5,7.1,5,6.5C5,5.67,5.67,5,6.5,5
|
||||
c0.6,0,1.11,0.35,1.35,0.86l0.73-0.73C8.14,4.45,7.37,4,6.5,4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
20
front/public/images/svg/menu.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<?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 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
|
||||
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
|
||||
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
|
||||
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 914 B |
6
front/public/images/svg/radio.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?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 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
|
||||
<circle cx="3" cy="3" r="3"/>
|
||||
</svg>
|
After Width: | Height: | Size: 384 B |
15
front/public/images/svg/upload.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<?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 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
|
||||
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
|
||||
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
|
||||
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
|
||||
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
|
||||
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
|
||||
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
|
||||
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
51
front/src/api/api.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { BASE_URL } from './constants';
|
||||
import { ApiResponse } from './types';
|
||||
import { unpack } from './utils';
|
||||
|
||||
const send = async <T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<ApiResponse<T>> => {
|
||||
const fullURL = `${BASE_URL}/${url}`;
|
||||
const fullInit: RequestInit = { ...init };
|
||||
try {
|
||||
const response = await fetch(fullURL, fullInit);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: null,
|
||||
error: { status: response.status, message: 'Something went wrong' },
|
||||
};
|
||||
}
|
||||
const raw = await response.json();
|
||||
const data: T = unpack(raw);
|
||||
return { data: data, error: null };
|
||||
} catch {
|
||||
return {
|
||||
data: null,
|
||||
error: { status: 0, message: 'Something went wrong' },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const api = {
|
||||
get: async <T>(url: string) => {
|
||||
return send<T>(url, { method: 'GET' });
|
||||
},
|
||||
post: async <T>(url: string, body: unknown) => {
|
||||
return send<T>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
put: async <T>(url: string, body: unknown) => {
|
||||
return send<T>(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
delete: async <T>(url: string) => {
|
||||
return send<T>(url, { method: 'DELETE' });
|
||||
},
|
||||
};
|
1
front/src/api/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const BASE_URL = 'http://localhost:8000';
|
9
front/src/api/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type ApiError = {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T | null;
|
||||
error: ApiError | null;
|
||||
};
|
23
front/src/api/utils.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const toCamelCase = (str: string) => {
|
||||
return str
|
||||
.split(/[_\s-]+|(?=[A-Z])/)
|
||||
.map((word, index) =>
|
||||
index === 0
|
||||
? word.toLowerCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const unpack = (obj: unknown) => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => unpack(item));
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
const newKey = toCamelCase(key);
|
||||
acc[newKey] = unpack(value);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
};
|
7
front/src/api/wind/constants.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const WIND_ENDPOINTS = {
|
||||
turbines: 'api/wind/turbines',
|
||||
turbineType: 'api/wind/turbine_type',
|
||||
parks: 'api/wind/parks',
|
||||
park: 'api/wind/park',
|
||||
parkTurbine: 'api/wind/park_turbine',
|
||||
};
|
2
front/src/api/wind/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './service';
|
||||
export * from './types';
|
117
front/src/api/wind/service.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { ApiResponse } from '@api/types';
|
||||
import { ParkFormValues } from '@components/pages/park-page/types';
|
||||
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||
|
||||
import { api } from '../api';
|
||||
import { WIND_ENDPOINTS } from './constants';
|
||||
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
|
||||
import { packPark, packParkTurbine, packTurbineTypes } from './utils';
|
||||
|
||||
export const getTurbineTypes = () => {
|
||||
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
|
||||
};
|
||||
|
||||
export const getTurbineType = (id: string) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.get<TurbineType>(url);
|
||||
};
|
||||
|
||||
export const createTurbineTypes = (
|
||||
formValues: Partial<TurbineTypeFormValues>,
|
||||
) => {
|
||||
return api.post<TurbineType>(
|
||||
WIND_ENDPOINTS.turbineType,
|
||||
packTurbineTypes(formValues),
|
||||
);
|
||||
};
|
||||
|
||||
export const editTurbineTypes = (
|
||||
formValues: Partial<TurbineTypeFormValues>,
|
||||
id: string,
|
||||
) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.put<TurbineType>(url, packTurbineTypes(formValues));
|
||||
};
|
||||
|
||||
export const deleteTurbineType = (id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.delete(url);
|
||||
};
|
||||
|
||||
export const getParks = () => {
|
||||
return api.get<Park[]>(WIND_ENDPOINTS.parks);
|
||||
};
|
||||
|
||||
export const getPark = (id: string) => {
|
||||
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||
return api.get<Park>(url);
|
||||
};
|
||||
|
||||
export const getParkTurbines = (id: string) => {
|
||||
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
|
||||
return api.get<ParkTurbine[]>(url);
|
||||
};
|
||||
|
||||
export const getParkWithTurbines = async (
|
||||
id: string,
|
||||
): Promise<ApiResponse<ParkWithTurbines>> => {
|
||||
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
|
||||
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
|
||||
const parkPesponse = await api.get<Park>(parkURL);
|
||||
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
|
||||
return {
|
||||
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
|
||||
error: parkPesponse.error || turbinesResponse.error || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const createPark = async (formValues: Partial<ParkFormValues>) => {
|
||||
const parkPesponse = await api.post<Park>(
|
||||
WIND_ENDPOINTS.park,
|
||||
packPark(formValues),
|
||||
);
|
||||
await Promise.all(
|
||||
formValues.turbines?.map((t) => {
|
||||
return api.post(
|
||||
WIND_ENDPOINTS.parkTurbine,
|
||||
packParkTurbine(t, parkPesponse.data.id),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return getParkWithTurbines(String(parkPesponse.data.id));
|
||||
};
|
||||
|
||||
export const updatePark = async (
|
||||
formValues: Partial<ParkFormValues>,
|
||||
id: string,
|
||||
) => {
|
||||
const parkPesponse = await api.put<Park>(
|
||||
`${WIND_ENDPOINTS.park}/${id}`,
|
||||
packPark(formValues),
|
||||
);
|
||||
await Promise.all(
|
||||
formValues.turbines?.map((t) => {
|
||||
if (t.new) {
|
||||
return api.post(
|
||||
WIND_ENDPOINTS.parkTurbine,
|
||||
packParkTurbine(t, parkPesponse.data.id),
|
||||
);
|
||||
}
|
||||
if (t.delete) {
|
||||
return api.delete(
|
||||
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
|
||||
);
|
||||
}
|
||||
return api.put(
|
||||
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
|
||||
packParkTurbine(t, parkPesponse.data.id),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return getParkWithTurbines(id);
|
||||
};
|
||||
|
||||
export const deletePark = (id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||
return api.delete(url);
|
||||
};
|
32
front/src/api/wind/types.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export type TurbineType = {
|
||||
id: number;
|
||||
name: string;
|
||||
height: number;
|
||||
bladeLength: number;
|
||||
};
|
||||
|
||||
export type Park = {
|
||||
id: number;
|
||||
name: string;
|
||||
centerLatitude: number;
|
||||
centerLongitude: number;
|
||||
};
|
||||
|
||||
export type ParkTurbine = {
|
||||
id: number;
|
||||
name: string;
|
||||
height: number;
|
||||
bladeLength: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
angle: number;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
export type ParkWithTurbines = {
|
||||
id: number;
|
||||
name: string;
|
||||
centerLatitude: number;
|
||||
centerLongitude: number;
|
||||
turbines: ParkTurbine[];
|
||||
};
|
32
front/src/api/wind/utils.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
ParkFormTurbine,
|
||||
ParkFormValues,
|
||||
} from '@components/pages/park-page/types';
|
||||
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||
|
||||
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
Height: parseInt(values.height || '0'),
|
||||
BladeLength: parseInt(values.bladeLength || '0'),
|
||||
};
|
||||
};
|
||||
|
||||
export const packPark = (values: Partial<ParkFormValues>) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
CenterLatitude: parseInt(values.centerLatitude || '0'),
|
||||
CenterLongitude: parseInt(values.centerLongitude || '0'),
|
||||
};
|
||||
};
|
||||
|
||||
export const packParkTurbine = (turbine: ParkFormTurbine, parkId: number) => {
|
||||
return {
|
||||
wind_park_id: parkId,
|
||||
turbine_id: turbine.id,
|
||||
x_offset: parseInt(turbine.xOffset || '0'),
|
||||
y_offset: parseInt(turbine.yOffset || '0'),
|
||||
angle: parseInt(turbine.angle || '0'),
|
||||
comment: turbine.comment ?? '',
|
||||
};
|
||||
};
|
19
front/src/components/_func.scss
Normal file
@ -0,0 +1,19 @@
|
||||
@function scale($values, $factor) {
|
||||
@if type-of($values) == 'list' {
|
||||
$m-values: ();
|
||||
@each $value in $values {
|
||||
$m-values: append($m-values, $value * $factor);
|
||||
}
|
||||
@return $m-values;
|
||||
} @else {
|
||||
@return nth($values, 1) * $factor;
|
||||
}
|
||||
}
|
||||
|
||||
@function m($values) {
|
||||
@return scale($values, 1.25);
|
||||
}
|
||||
|
||||
@function l($values) {
|
||||
@return scale($values, 1.5);
|
||||
}
|
5
front/src/components/_mixins.scss
Normal file
@ -0,0 +1,5 @@
|
||||
@mixin on-mobile {
|
||||
@media (width <= 800px) {
|
||||
@content;
|
||||
}
|
||||
}
|
@ -1,37 +1,67 @@
|
||||
@mixin light {
|
||||
color-scheme: light;
|
||||
|
||||
--clr-primary: #363a4e;
|
||||
--clr-primary: #4176FF;
|
||||
--clr-primary-o50: #4176FF80;
|
||||
--clr-primary-hover: #638FFF;
|
||||
--clr-on-primary: #FFFFFF;
|
||||
|
||||
--clr-secondary: #bca59f;
|
||||
--clr-secondary: #E1EAF8;
|
||||
--clr-secondary-hover: #E8ECF0;
|
||||
--clr-on-secondary: #0D0D0D;
|
||||
|
||||
--clr-accent: #80845c;
|
||||
--clr-accent-o50: #80845c80;
|
||||
|
||||
--clr-layer-100: #EAECF1;
|
||||
--clr-layer-200: #DFE1E6;
|
||||
--clr-layer-100: #EBEEF0;
|
||||
--clr-layer-200: #FFFFFF;
|
||||
--clr-layer-300: #FFFFFF;
|
||||
--clr-layer-300-hover: #EAEAEA;
|
||||
|
||||
--clr-text-100: #0f1015;
|
||||
--clr-text-100: #8D8D8D;
|
||||
--clr-text-200: #6C7480;
|
||||
--clr-text-300: #1D1F20;
|
||||
--clr-text-primary: #3865DA;
|
||||
|
||||
--clr-shadow: #363a4e1A;
|
||||
--clr-border-100: #DFDFDF;
|
||||
--clr-border-200: #D8D8D8;
|
||||
|
||||
--clr-shadow-100: #0000001A;
|
||||
--clr-shadow-200: #00000026;
|
||||
|
||||
--clr-ripple: #1D1F2026;
|
||||
|
||||
--clr-error: #E54B4B;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--clr-primary: #b1b5c9;
|
||||
--clr-primary: #3865DA;
|
||||
--clr-primary-o50: #3865DA80;
|
||||
--clr-primary-hover: #4073F7;
|
||||
--clr-primary-disabled: #334570;
|
||||
--clr-on-primary: #FFFFFF;
|
||||
|
||||
--clr-secondary: #604943;
|
||||
--clr-secondary: #3F3F3F;
|
||||
--clr-secondary-hover: #4D4D4D;
|
||||
--clr-secondary-disabled: #323232;
|
||||
--clr-on-secondary: #FFFFFF;
|
||||
|
||||
--clr-accent: #9fa37b;
|
||||
--clr-accent-o50: #9fa37b80;
|
||||
--clr-layer-100: #1B1B1B;
|
||||
--clr-layer-200: #232323;
|
||||
--clr-layer-300: #2F2F2F;
|
||||
--clr-layer-300-hover: #3E3E3E;
|
||||
|
||||
--clr-layer-100: #0E1015;
|
||||
--clr-layer-200: #191B20;
|
||||
--clr-layer-300: #2E3139;
|
||||
--clr-text-100: #888888;
|
||||
--clr-text-200: #C5C5C5;
|
||||
--clr-text-300: #F0F0F0;
|
||||
--clr-text-primary: #4176FF;
|
||||
|
||||
--clr-text-100: #eaebf0;
|
||||
--clr-border-100: #3D3D3D;
|
||||
--clr-border-200: #545454;
|
||||
|
||||
--clr-shadow: transparent;
|
||||
--clr-shadow-100: #0000001A;
|
||||
--clr-shadow-200: #00000026;
|
||||
|
||||
--clr-ripple: #F0F0F026;
|
||||
|
||||
--clr-error: #FF6363;
|
||||
}
|
||||
|
35
front/src/components/app/component.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import './styles.scss';
|
||||
import '@public/fonts/styles.css';
|
||||
|
||||
import { MainLayout } from '@components/layouts';
|
||||
import {
|
||||
ParkPage,
|
||||
ParksPage,
|
||||
TurbineTypePage,
|
||||
TurbineTypesPage,
|
||||
} from '@components/pages';
|
||||
import { ROUTES } from '@utils/route';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route
|
||||
path={ROUTES.turbineTypes.path}
|
||||
element={<TurbineTypesPage />}
|
||||
/>
|
||||
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
|
||||
<Route path={ROUTES.parks.path} element={<ParksPage />} />
|
||||
<Route path={ROUTES.park.path} element={<ParkPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
1
front/src/components/app/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { App } from './component';
|
@ -1,22 +0,0 @@
|
||||
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;
|
@ -1,20 +0,0 @@
|
||||
*,
|
||||
*::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;
|
||||
}
|
@ -1,6 +1,12 @@
|
||||
@use './reset';
|
||||
@use './theme' as theme;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
@include theme.light;
|
||||
}
|
||||
@ -16,11 +22,23 @@ html[data-theme='default'] {
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
--td-100: 0.1s;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Rubik, sans-serif;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
background-color: var(--clr-layer-100);
|
||||
font-family: Rubik, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
picture {
|
||||
display: block;
|
||||
}
|
||||
|
1
front/src/components/layouts/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { MainLayout } from './main-layout';
|
@ -1,3 +0,0 @@
|
||||
import MainLayout from './main-layout';
|
||||
|
||||
export { MainLayout };
|
22
front/src/components/layouts/main-layout/component.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Header, Sidebar } from '@components/ux';
|
||||
import { useDeviceType } from '@utils/device';
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function MainLayout() {
|
||||
const deviceType = useDeviceType();
|
||||
return (
|
||||
<div className={styles.mainLayout}>
|
||||
{deviceType === 'mobile' ? (
|
||||
<Header className={styles.header} />
|
||||
) : (
|
||||
<Sidebar className={styles.sidebar} />
|
||||
)}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,18 +1 @@
|
||||
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;
|
||||
export * from './component';
|
||||
|
@ -1,8 +1,38 @@
|
||||
@use '@components/mixins.scss' as m;
|
||||
|
||||
.mainLayout {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
grid-template:
|
||||
'header' auto
|
||||
'main' minmax(0, 1fr)
|
||||
/ minmax(0, 1fr);
|
||||
'sidebar main' minmax(0, 1fr)
|
||||
/ auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
grid-area: main;
|
||||
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
|
||||
|
||||
& > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@include m.on-mobile {
|
||||
.mainLayout {
|
||||
grid-template:
|
||||
'header' auto
|
||||
'main' minmax(0, 1fr)
|
||||
/ minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +0,0 @@
|
||||
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;
|
@ -1,3 +0,0 @@
|
||||
.about {
|
||||
padding: 10px;
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
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;
|
@ -1,12 +0,0 @@
|
||||
.home {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center
|
||||
}
|
4
front/src/components/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './park-page';
|
||||
export * from './parks-page';
|
||||
export * from './turbine-type-page';
|
||||
export * from './turbine-types-page';
|
@ -1,4 +0,0 @@
|
||||
import About from './about';
|
||||
import Home from './home/index';
|
||||
|
||||
export { About, Home };
|
128
front/src/components/pages/park-page/component.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import {
|
||||
createPark,
|
||||
getParkWithTurbines,
|
||||
ParkWithTurbines,
|
||||
updatePark,
|
||||
} from '@api/wind';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
Heading,
|
||||
NumberField,
|
||||
TextInput,
|
||||
} from '@components/ui';
|
||||
import { ParkTurbines } from '@components/ux';
|
||||
import { Controller, useForm } from '@utils/form';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { ParkFormValues } from './types';
|
||||
import { unpackPark } from './utils';
|
||||
|
||||
export function ParkPage() {
|
||||
const [park, setPark] = useState<ParkWithTurbines>(null);
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>(null);
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const route = useRoute();
|
||||
|
||||
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
|
||||
|
||||
const { id } = params;
|
||||
const isEdit = id !== 'new';
|
||||
const heading = isEdit ? 'Edit' : 'Create new';
|
||||
|
||||
const fetchPark = async () => {
|
||||
const response = await getParkWithTurbines(id);
|
||||
setPark(response.data);
|
||||
reset(unpackPark(response.data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
return;
|
||||
}
|
||||
fetchPark();
|
||||
}, [id]);
|
||||
|
||||
const handleFormSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
if (isEdit) {
|
||||
const { data, error } = await updatePark(getValues(), id);
|
||||
if (error) {
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
setPark(data);
|
||||
reset(unpackPark(data));
|
||||
} else {
|
||||
const { data, error } = await createPark(getValues());
|
||||
if (error) {
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
navigate(ROUTES.park.path.replace(':id', String(data.id)));
|
||||
}
|
||||
setPending(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (isEdit) {
|
||||
reset(unpackPark(park));
|
||||
} else {
|
||||
reset({});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page} onSubmit={handleFormSubmit}>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<form className={styles.form}>
|
||||
<header>
|
||||
<Heading tag="h3">{heading}</Heading>
|
||||
</header>
|
||||
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||
<div className={styles.inputBox}>
|
||||
<Controller
|
||||
{...control('centerLatitude')}
|
||||
render={(props) => (
|
||||
<NumberField label={{ text: 'Center Latitude' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('centerLongitude')}
|
||||
render={(props) => (
|
||||
<NumberField label={{ text: 'Center Longitude' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttonBox}>
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" pending={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Controller
|
||||
{...control('turbines')}
|
||||
render={(props) => (
|
||||
<ParkTurbines savedTurbines={park?.turbines ?? []} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Dialog
|
||||
open={Boolean(error)}
|
||||
heading="Error"
|
||||
message="Something went wrong"
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
<Button onClick={() => setError(null)}>Ok</Button>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
1
front/src/components/pages/park-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
28
front/src/components/pages/park-page/styles.module.scss
Normal file
@ -0,0 +1,28 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.inputBox {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.buttonBox {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
padding-top: 20px;
|
||||
gap: 10px;
|
||||
}
|
17
front/src/components/pages/park-page/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export type ParkFormTurbine = {
|
||||
id: number;
|
||||
name: string;
|
||||
xOffset: string;
|
||||
yOffset: string;
|
||||
angle: string;
|
||||
comment: string;
|
||||
new?: boolean;
|
||||
delete?: boolean;
|
||||
};
|
||||
|
||||
export type ParkFormValues = {
|
||||
name: string;
|
||||
centerLatitude: string;
|
||||
centerLongitude: string;
|
||||
turbines: ParkFormTurbine[];
|
||||
};
|
19
front/src/components/pages/park-page/utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ParkWithTurbines } from '@api/wind';
|
||||
|
||||
import { ParkFormValues } from './types';
|
||||
|
||||
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
|
||||
return {
|
||||
name: park.name,
|
||||
centerLatitude: String(park.centerLatitude),
|
||||
centerLongitude: String(park.centerLongitude),
|
||||
turbines: park.turbines.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
xOffset: String(t.xOffset),
|
||||
yOffset: String(t.yOffset),
|
||||
angle: String(t.angle),
|
||||
comment: t.comment,
|
||||
})),
|
||||
};
|
||||
};
|
64
front/src/components/pages/parks-page/component.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { deletePark, getParks, Park } from '@api/wind';
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function ParksPage() {
|
||||
const [parks, setParks] = useState<Park[]>([]);
|
||||
const [selected, setSelected] = useState<Park>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const fetchParks = async () => {
|
||||
const res = await getParks();
|
||||
setParks(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchParks();
|
||||
}, []);
|
||||
|
||||
const handleParkSelect = (items: Park[]) => {
|
||||
setSelected(items[0] ?? null);
|
||||
};
|
||||
|
||||
const handleDeleteButtonClick = async () => {
|
||||
await deletePark(selected.id);
|
||||
fetchParks();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Link to={ROUTES.park.path.replace(':id', 'new')}>
|
||||
<Button>Create new</Button>
|
||||
</Link>
|
||||
{selected && (
|
||||
<Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
)}
|
||||
{selected && (
|
||||
<Button variant="secondary" onClick={handleDeleteButtonClick}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.dataGridWrapper}>
|
||||
<DataGrid
|
||||
items={parks}
|
||||
columns={columns}
|
||||
getItemKey={({ id }) => String(id)}
|
||||
selectedItems={selected ? [selected] : []}
|
||||
onItemsSelect={handleParkSelect}
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
8
front/src/components/pages/parks-page/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||
import { Park } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<Park>[] = [
|
||||
{ name: 'Name', getText: (t) => t.name, width: 2 },
|
||||
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
|
||||
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
|
||||
];
|
1
front/src/components/pages/parks-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
19
front/src/components/pages/parks-page/styles.module.scss
Normal file
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dataGridWrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
}
|
101
front/src/components/pages/turbine-type-page/component.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
createTurbineTypes,
|
||||
editTurbineTypes,
|
||||
getTurbineType,
|
||||
TurbineType,
|
||||
} from '@api/wind';
|
||||
import { Button, Heading, NumberField, TextInput } from '@components/ui';
|
||||
import { Controller, useForm } from '@utils/form';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { TurbineTypeFormValues } from './types';
|
||||
import { unpackTurbineType } from './utils';
|
||||
|
||||
export function TurbineTypePage() {
|
||||
const [turbineType, setTurbineType] = useState<TurbineType>(null);
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const route = useRoute();
|
||||
|
||||
const { register, control, getValues, reset } =
|
||||
useForm<TurbineTypeFormValues>({});
|
||||
|
||||
const { id } = params;
|
||||
const isEdit = id !== 'new';
|
||||
const heading = isEdit ? 'Edit' : 'Create new';
|
||||
|
||||
const fetchTurbineType = async () => {
|
||||
const response = await getTurbineType(id);
|
||||
setTurbineType(response.data);
|
||||
reset(unpackTurbineType(response.data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
return;
|
||||
}
|
||||
fetchTurbineType();
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
if (isEdit) {
|
||||
const response = await editTurbineTypes(getValues(), id);
|
||||
setTurbineType(response.data);
|
||||
reset(unpackTurbineType(response.data));
|
||||
} else {
|
||||
const response = await createTurbineTypes(getValues());
|
||||
navigate(
|
||||
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
|
||||
);
|
||||
}
|
||||
setPending(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (isEdit) {
|
||||
reset(unpackTurbineType(turbineType));
|
||||
} else {
|
||||
reset({});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page} onSubmit={handleSubmit}>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<form className={styles.form}>
|
||||
<header>
|
||||
<Heading tag="h3">{heading}</Heading>
|
||||
</header>
|
||||
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||
<div className={styles.inputBox}>
|
||||
<Controller
|
||||
{...control('height')}
|
||||
render={(props) => (
|
||||
<NumberField label={{ text: 'Height' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('bladeLength')}
|
||||
render={(props) => (
|
||||
<NumberField label={{ text: 'Blade length' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttonBox}>
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" pending={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
1
front/src/components/pages/turbine-type-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
@ -0,0 +1,28 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.inputBox {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.buttonBox {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
padding-top: 20px;
|
||||
gap: 10px;
|
||||
}
|
5
front/src/components/pages/turbine-type-page/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type TurbineTypeFormValues = {
|
||||
name: string;
|
||||
height: string;
|
||||
bladeLength: string;
|
||||
};
|
13
front/src/components/pages/turbine-type-page/utils.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { TurbineType } from '@api/wind';
|
||||
|
||||
import { TurbineTypeFormValues } from './types';
|
||||
|
||||
export const unpackTurbineType = (
|
||||
turbineType: TurbineType,
|
||||
): TurbineTypeFormValues => {
|
||||
return {
|
||||
name: turbineType.name,
|
||||
height: String(turbineType.height),
|
||||
bladeLength: String(turbineType.bladeLength),
|
||||
};
|
||||
};
|
66
front/src/components/pages/turbine-types-page/component.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function TurbineTypesPage() {
|
||||
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
|
||||
const [selected, setSelected] = useState<TurbineType>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const fetchTurbineTypes = async () => {
|
||||
const res = await getTurbineTypes();
|
||||
setTurbineTypes(res.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTurbineTypes();
|
||||
}, []);
|
||||
|
||||
const handleTurbineTypeSelect = (items: TurbineType[]) => {
|
||||
setSelected(items[0] ?? null);
|
||||
};
|
||||
|
||||
const handleDeleteButtonClick = async () => {
|
||||
await deleteTurbineType(selected.id);
|
||||
fetchTurbineTypes();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
|
||||
<Button>Create new</Button>
|
||||
</Link>
|
||||
{selected && (
|
||||
<Link
|
||||
to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
|
||||
>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
)}
|
||||
{selected && (
|
||||
<Button variant="secondary" onClick={handleDeleteButtonClick}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.dataGridWrapper}>
|
||||
<DataGrid
|
||||
items={turbineTypes}
|
||||
columns={columns}
|
||||
getItemKey={({ id }) => String(id)}
|
||||
selectedItems={selected ? [selected] : []}
|
||||
onItemsSelect={handleTurbineTypeSelect}
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||
import { TurbineType } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<TurbineType>[] = [
|
||||
{ name: 'Name', getText: (t) => t.name, width: 2 },
|
||||
{ name: 'Height', getText: (t) => String(t.height) },
|
||||
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
|
||||
];
|
1
front/src/components/pages/turbine-types-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dataGridWrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
}
|
63
front/src/components/ui/animation/fade/component.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
CSSProperties,
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { FadeProps } from './types';
|
||||
|
||||
export function FadeInner(
|
||||
{
|
||||
visible,
|
||||
duration = 200,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: Omit<FadeProps, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [visibleInternal, setVisibleInternal] = useState<boolean>(visible);
|
||||
|
||||
const classNames = clsx(
|
||||
styles.fade,
|
||||
{ [styles.invisible]: !visible },
|
||||
className,
|
||||
);
|
||||
|
||||
const inlineStyle = {
|
||||
...style,
|
||||
'--animation-duration': `${duration}ms`,
|
||||
} as CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setVisibleInternal(true);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
||||
if (event.animationName === styles.fadeout) {
|
||||
setVisibleInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visibleInternal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
ref={ref}
|
||||
style={inlineStyle}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Fade = forwardRef(FadeInner);
|
2
front/src/components/ui/animation/fade/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Fade } from './component';
|
||||
export { type FadeProps } from './types';
|
29
front/src/components/ui/animation/fade/styles.module.scss
Normal file
@ -0,0 +1,29 @@
|
||||
.fade {
|
||||
animation: fadein var(--animation-duration) ease-in-out;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
animation: fadeout var(--animation-duration) forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
}
|
6
front/src/components/ui/animation/fade/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export type FadeProps = {
|
||||
visible: boolean;
|
||||
duration?: number;
|
||||
} & ComponentProps<'div'>;
|
2
front/src/components/ui/animation/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './fade';
|
||||
export * from './ripple';
|
65
front/src/components/ui/animation/ripple/component.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { calcRippleWaveStyle } from './utils';
|
||||
|
||||
export function Ripple() {
|
||||
const rippleRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clean = () => {
|
||||
document.removeEventListener('touchend', clean);
|
||||
document.removeEventListener('mouseup', clean);
|
||||
if (!rippleRef.current) {
|
||||
return;
|
||||
}
|
||||
const { lastChild: wave } = rippleRef.current;
|
||||
if (!wave || !(wave instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
wave.dataset.isMouseReleased = 'true';
|
||||
if (wave.dataset.isAnimationComplete) {
|
||||
wave.classList.replace(styles.visible, styles.invisible);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||
const { target: wave, animationName } = event;
|
||||
if (!(wave instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (animationName === styles.fadein) {
|
||||
wave.dataset.isAnimationComplete = 'true';
|
||||
if (wave.dataset.isMouseReleased) {
|
||||
wave.classList.replace(styles.visible, styles.invisible);
|
||||
}
|
||||
} else {
|
||||
wave.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const addWave = (x: number, y: number) => {
|
||||
const wave = document.createElement('div');
|
||||
const style = calcRippleWaveStyle(x, y, rippleRef.current);
|
||||
Object.assign(wave.style, style);
|
||||
wave.className = clsx(styles.wave, styles.visible);
|
||||
wave.addEventListener('animationend', handleAnimationEnd);
|
||||
rippleRef.current.appendChild(wave);
|
||||
document.addEventListener('touchend', clean);
|
||||
document.addEventListener('mouseup', clean);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const { pageX, pageY } = event;
|
||||
addWave(pageX, pageY);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.ripple}
|
||||
ref={rippleRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
/>
|
||||
);
|
||||
}
|
1
front/src/components/ui/animation/ripple/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { Ripple } from './component';
|
42
front/src/components/ui/animation/ripple/styles.module.scss
Normal file
@ -0,0 +1,42 @@
|
||||
.ripple {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
}
|
||||
|
||||
.wave {
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background-color: var(--clr-ripple);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
animation: fadein 0.25s linear;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
animation: fadeout 0.25s linear;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
15
front/src/components/ui/animation/ripple/utils.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { px } from '@utils/css';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export const calcRippleWaveStyle = (
|
||||
x: number,
|
||||
y: number,
|
||||
ripple: HTMLDivElement,
|
||||
): CSSProperties => {
|
||||
const wrapperRect = ripple.getBoundingClientRect();
|
||||
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
|
||||
const radius = diameter / 2;
|
||||
const left = px(x - wrapperRect.left - radius);
|
||||
const top = px(y - wrapperRect.top - radius);
|
||||
return { left, top, width: px(diameter), height: px(diameter) };
|
||||
};
|
119
front/src/components/ui/autocomplete/component.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
||||
import { useMissClick } from '@utils/miss-click';
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Menu } from '../menu';
|
||||
import { Popover } from '../popover';
|
||||
import { TextInput } from '../text-input';
|
||||
import styles from './styles.module.scss';
|
||||
import { AutocompleteProps } from './types';
|
||||
|
||||
function AutocompleteInner<T>(
|
||||
{
|
||||
options,
|
||||
value,
|
||||
getOptionKey,
|
||||
getOptionLabel,
|
||||
onChange,
|
||||
scale = 'm',
|
||||
label = {},
|
||||
name,
|
||||
id,
|
||||
}: Omit<AutocompleteProps<T>, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const autocompleteRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLUListElement | null>(null);
|
||||
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuVisible, setMenuVisible] = useState<boolean>(false);
|
||||
const [text, setText] = useState<string>('');
|
||||
|
||||
useImperativeHandle(ref, () => autocompleteRef.current, []);
|
||||
|
||||
useMissClick({
|
||||
callback: () => setMenuVisible(false),
|
||||
enabled: menuVisible,
|
||||
whitelist: [autocompleteRef, menuRef],
|
||||
});
|
||||
|
||||
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
|
||||
[styles.menuVisible]: menuVisible,
|
||||
});
|
||||
|
||||
const filteredOptions = options.filter((option) => {
|
||||
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||
const raw = text.trim().toLocaleLowerCase();
|
||||
return label.includes(raw);
|
||||
});
|
||||
|
||||
const handleInputClick = () => {
|
||||
setMenuVisible(!menuVisible);
|
||||
};
|
||||
|
||||
const handleMenuSelect = (option: T) => {
|
||||
setMenuVisible(false);
|
||||
onChange?.(option);
|
||||
setText('');
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setText(value);
|
||||
const option = options.find((option) => {
|
||||
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||
const raw = value.toLocaleLowerCase();
|
||||
return label === raw;
|
||||
});
|
||||
onChange?.(option ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={autocompleteClassName} ref={autocompleteRef}>
|
||||
<TextInput
|
||||
value={value ? getOptionLabel(value) : text}
|
||||
onClick={handleInputClick}
|
||||
scale={scale}
|
||||
label={label}
|
||||
name={name}
|
||||
id={id}
|
||||
wrapper={{ ref: inputWrapperRef }}
|
||||
onChange={handleInputChange}
|
||||
rightNode={
|
||||
<div className={styles.iconBox}>
|
||||
<ArrowDownIcon className={styles.icon} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Popover
|
||||
visible={menuVisible}
|
||||
anchorRef={autocompleteRef}
|
||||
position="bottom"
|
||||
horizontalAlign="stretch"
|
||||
flip
|
||||
element={
|
||||
<div className={styles.menuWrapper}>
|
||||
<Menu
|
||||
options={filteredOptions}
|
||||
selected={value}
|
||||
getOptionKey={getOptionKey}
|
||||
getOptionLabel={getOptionLabel}
|
||||
onSelect={handleMenuSelect}
|
||||
ref={menuRef}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
|
||||
props: AutocompleteProps<T>,
|
||||
) => ReturnType<typeof AutocompleteInner>;
|
3
front/src/components/ui/autocomplete/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Autocomplete } from './component';
|
||||
export { AutocompletePreview } from './preview';
|
||||
export { type AutocompleteProps } from './types';
|
44
front/src/components/ui/autocomplete/preview.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { PreviewArticle } from '@components/ui/preview';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Autocomplete } from './component';
|
||||
|
||||
export function AutocompletePreview() {
|
||||
const [selectValue, setSelectValue] = useState<string>();
|
||||
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
|
||||
|
||||
return (
|
||||
<PreviewArticle title="Autocomplete">
|
||||
<Autocomplete
|
||||
options={options}
|
||||
getOptionKey={(o) => o}
|
||||
getOptionLabel={(o) => o}
|
||||
label={{ text: 'Select your favorite fruit' }}
|
||||
scale="s"
|
||||
value={selectValue}
|
||||
onChange={(o) => setSelectValue(o)}
|
||||
name="fruit"
|
||||
/>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
getOptionKey={(o) => o}
|
||||
getOptionLabel={(o) => o}
|
||||
label={{ text: 'Select your favorite fruit' }}
|
||||
scale="m"
|
||||
value={selectValue}
|
||||
onChange={(o) => setSelectValue(o)}
|
||||
name="fruit"
|
||||
/>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
getOptionKey={(o) => o}
|
||||
getOptionLabel={(o) => o}
|
||||
label={{ text: 'Select your favorite fruit' }}
|
||||
scale="l"
|
||||
value={selectValue}
|
||||
onChange={(o) => setSelectValue(o)}
|
||||
name="fruit"
|
||||
/>
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
62
front/src/components/ui/autocomplete/styles.module.scss
Normal file
@ -0,0 +1,62 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--clr-text-100);
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
}
|
||||
|
||||
.fade {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.menuVisible {
|
||||
.icon {
|
||||
rotate: 180deg;
|
||||
}
|
||||
}
|
||||
|
||||
.menuWrapper {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
$padding-right: 7px;
|
||||
$size: 10px;
|
||||
|
||||
.s {
|
||||
.iconBox {
|
||||
padding-right: $padding-right;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
}
|
||||
|
||||
.m {
|
||||
.iconBox {
|
||||
padding-right: f.m($padding-right);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: f.m($size);
|
||||
height: f.m($size);
|
||||
}
|
||||
}
|
||||
|
||||
.l {
|
||||
.iconBox {
|
||||
padding-right: f.l($padding-right);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: f.l($size);
|
||||
height: f.l($size);
|
||||
}
|
||||
}
|
16
front/src/components/ui/autocomplete/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ComponentProps, Key } from 'react';
|
||||
|
||||
import { LabelProps } from '../label';
|
||||
import { Scale } from '../types';
|
||||
|
||||
export type AutocompleteProps<T> = {
|
||||
options: T[];
|
||||
value?: T;
|
||||
getOptionKey: (option: T) => Key;
|
||||
getOptionLabel: (option: T) => string;
|
||||
onChange?: (option: T) => void;
|
||||
scale?: Scale;
|
||||
label?: LabelProps;
|
||||
name?: string;
|
||||
id?: string;
|
||||
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
42
front/src/components/ui/button/component.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { Ripple } from '../animation/ripple/component';
|
||||
import { Comet } from '../comet';
|
||||
import { RawButton } from '../raw';
|
||||
import { COMET_VARIANT_MAP } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
import { ButtonProps } from './types.js';
|
||||
|
||||
function ButtonInner(
|
||||
{
|
||||
variant = 'primary',
|
||||
scale = 'm',
|
||||
pending = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const buttonClassName = clsx(
|
||||
styles.button,
|
||||
styles[variant],
|
||||
styles[scale],
|
||||
{ [styles.pending]: pending },
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<RawButton className={buttonClassName} ref={ref} {...props}>
|
||||
{pending && (
|
||||
<div className={styles.cometWrapper}>
|
||||
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.childrenWrapper}>{children}</div>
|
||||
<Ripple />
|
||||
</RawButton>
|
||||
);
|
||||
}
|
||||
|
||||
export const Button = forwardRef(ButtonInner);
|
10
front/src/components/ui/button/constants.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { CometProps } from '../comet';
|
||||
import { ButtonProps } from './types';
|
||||
|
||||
export const COMET_VARIANT_MAP: Record<
|
||||
ButtonProps['variant'],
|
||||
CometProps['variant']
|
||||
> = {
|
||||
primary: 'onPrimary',
|
||||
secondary: 'onSecondary',
|
||||
};
|
3
front/src/components/ui/button/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Button } from './component';
|
||||
export { ButtonPreview } from './preview';
|
||||
export { type ButtonProps } from './types';
|
@ -1,23 +0,0 @@
|
||||
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;
|
49
front/src/components/ui/button/preview.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { PreviewArticle, PreviewBox } from '@components/ui/preview';
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from './component';
|
||||
|
||||
export function ButtonPreview() {
|
||||
return (
|
||||
<PreviewArticle title="Button">
|
||||
<PreviewBox>
|
||||
<Button scale="s">Button</Button>
|
||||
<Button scale="m">Button</Button>
|
||||
<Button scale="l">Button</Button>
|
||||
</PreviewBox>
|
||||
<PreviewBox>
|
||||
<Button scale="s" pending>
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="m" pending>
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="l" pending>
|
||||
Button
|
||||
</Button>
|
||||
</PreviewBox>
|
||||
<PreviewBox>
|
||||
<Button scale="s" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="m" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="l" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
</PreviewBox>
|
||||
<PreviewBox>
|
||||
<Button scale="s" variant="secondary" pending>
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="m" variant="secondary" pending>
|
||||
Button
|
||||
</Button>
|
||||
<Button scale="l" variant="secondary" pending>
|
||||
Button
|
||||
</Button>
|
||||
</PreviewBox>
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
@ -1,39 +1,75 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.button {
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
transition: all 0.1s ease-in-out;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
filter: brightness(0.8);
|
||||
.cometWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pending {
|
||||
pointer-events: none;
|
||||
|
||||
.childrenWrapper {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: var(--clr-primary);
|
||||
color: var(--clr-layer-100);
|
||||
color: var(--clr-on-primary);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--clr-secondary);
|
||||
color: var(--clr-text-100);
|
||||
color: var(--clr-on-secondary);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-secondary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$padding: 10px 16px;
|
||||
$border-radius: 8px;
|
||||
$font-size: 12px;
|
||||
|
||||
.s {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
padding: $padding;
|
||||
border-radius: $border-radius;
|
||||
font-size: $font-size;
|
||||
}
|
||||
|
||||
.m {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
padding: f.m($padding);
|
||||
border-radius: f.m($border-radius);
|
||||
font-size: f.m($font-size);
|
||||
}
|
||||
|
||||
.l {
|
||||
padding: 12px 24px;
|
||||
font-size: 20px;
|
||||
padding: f.l($padding);
|
||||
border-radius: f.l($border-radius);
|
||||
font-size: f.l($font-size);
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { RawButtonProps } from '../raw/raw-button/types';
|
||||
import { Scale } from '@components/ui/types';
|
||||
|
||||
import { RawButtonProps } from '../raw';
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?: 'primary' | 'secondary';
|
||||
scale?: 's' | 'm' | 'l';
|
||||
scale?: Scale;
|
||||
pending?: boolean;
|
||||
} & RawButtonProps;
|
||||
|
36
front/src/components/ui/calendar/component.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
import { CalendarDays } from './components';
|
||||
import { CalendarProps } from './types';
|
||||
|
||||
function CalendarInner(
|
||||
{ value, onChange, min, max, ...props }: Omit<CalendarProps, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [date, setDate] = useState<Date>(value ? new Date(value) : new Date());
|
||||
|
||||
useEffect(() => {
|
||||
setDate(value ? new Date(value) : new Date());
|
||||
}, [value]);
|
||||
|
||||
const handleMonthChange = (delta: number) => {
|
||||
const newDate = new Date(date);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
setDate(newDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...props} ref={ref}>
|
||||
<CalendarDays
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
date={date}
|
||||
onMonthChange={handleMonthChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Calendar = forwardRef(CalendarInner);
|
@ -0,0 +1,81 @@
|
||||
import { IconButton } from '@components/ui/icon-button';
|
||||
import { RawButton } from '@components/ui/raw';
|
||||
import { Span } from '@components/ui/span';
|
||||
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
||||
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
|
||||
import clsx from 'clsx';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { DAYS_OF_THE_WEEK, MONTHS } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
import { CalendarDaysProps } from './types';
|
||||
import { getCalendarDays } from './utils';
|
||||
|
||||
export function CalendarDays({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
date,
|
||||
onMonthChange,
|
||||
}: CalendarDaysProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
const days = useMemo(() => {
|
||||
return getCalendarDays({
|
||||
year: date.getFullYear(),
|
||||
monthIndex: date.getMonth(),
|
||||
today,
|
||||
selectedDateStr: value,
|
||||
min,
|
||||
max,
|
||||
});
|
||||
}, [date, min, max]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className={styles.header}>
|
||||
<Span color="t300" className={styles.title}>
|
||||
{MONTHS[date.getMonth()]} {date.getFullYear()}
|
||||
</Span>
|
||||
<IconButton
|
||||
className={styles.turnButton}
|
||||
onClick={() => onMonthChange(-1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className={styles.turnButton}
|
||||
onClick={() => onMonthChange(1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
</header>
|
||||
<div className={styles.daysGrid}>
|
||||
{DAYS_OF_THE_WEEK.map((day) => (
|
||||
<Span className={styles.dayOfTheWeek} scale="none" key={day}>
|
||||
{day}
|
||||
</Span>
|
||||
))}
|
||||
{days.map((day, index) => (
|
||||
<RawButton
|
||||
key={index}
|
||||
disabled={day.isDisabled}
|
||||
className={clsx(styles.day, {
|
||||
[styles.selectedDay]: day.isSelected,
|
||||
[styles.currentMonthDay]: day.isCurrentMonth,
|
||||
})}
|
||||
onClick={() => handleChange(day.string)}
|
||||
>
|
||||
{day.number}
|
||||
{day.isToday && <div className={styles.todayIndicator} />}
|
||||
</RawButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
export const DAYS_OF_THE_WEEK = [
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
'Sun',
|
||||
];
|
||||
|
||||
export const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
@ -0,0 +1,2 @@
|
||||
export { CalendarDays } from './component';
|
||||
export { type CalendarDaysProps } from './types';
|
@ -0,0 +1,78 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
padding-left: 5px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.turnButton {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.daysGrid {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(7, auto);
|
||||
}
|
||||
|
||||
.dayOfTheWeek {
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: var(--clr-text-100);
|
||||
cursor: pointer;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-layer-300-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.currentMonthDay {
|
||||
color: var(--clr-text-300);
|
||||
}
|
||||
|
||||
.selectedDay {
|
||||
background-color: var(--clr-primary);
|
||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||
color: var(--clr-on-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-primary-hover);
|
||||
}
|
||||
|
||||
.todayIndicator {
|
||||
background-color: var(--clr-on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.todayIndicator {
|
||||
position: absolute;
|
||||
bottom: 12%;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--clr-text-300);
|
||||
transform: translateX(-50%);
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
export type CalendarDay = {
|
||||
number: number;
|
||||
isDisabled: boolean;
|
||||
isSelected: boolean;
|
||||
isToday: boolean;
|
||||
string: string;
|
||||
isCurrentMonth: boolean;
|
||||
};
|
||||
|
||||
export type CalendarDaysProps = {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
min: Date | null;
|
||||
max: Date | null;
|
||||
date: Date;
|
||||
onMonthChange: (delta: number) => void;
|
||||
};
|
||||
|
||||
export type GetCalendarDaysParams = {
|
||||
year: number;
|
||||
monthIndex: number;
|
||||
today: Date;
|
||||
selectedDateStr?: string;
|
||||
min: Date | null;
|
||||
max: Date | null;
|
||||
};
|
@ -0,0 +1,59 @@
|
||||
import { CalendarDay, GetCalendarDaysParams } from './types';
|
||||
|
||||
const addDays = (date: Date, days: number) => {
|
||||
date.setDate(date.getDate() + days);
|
||||
};
|
||||
|
||||
function dateToInputString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const daysAreEqual = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getDate() === date2.getDate() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getFullYear() === date2.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
export const getCalendarDays = ({
|
||||
year,
|
||||
monthIndex,
|
||||
today,
|
||||
selectedDateStr,
|
||||
min,
|
||||
max,
|
||||
}: GetCalendarDaysParams) => {
|
||||
const selectedDate = new Date(selectedDateStr);
|
||||
|
||||
const firstDayOfMonth = new Date(year, monthIndex, 1);
|
||||
const daysFromPrevMonth = (firstDayOfMonth.getDay() || 7) - 1;
|
||||
|
||||
const date = new Date(year, monthIndex, 1);
|
||||
addDays(date, -daysFromPrevMonth);
|
||||
const days: CalendarDay[] = [];
|
||||
|
||||
for (let i = 0; i < 42; i += 1) {
|
||||
const number = date.getDate();
|
||||
const isDisabled = (min && date < min) || (max && date > max);
|
||||
const isSelected = daysAreEqual(date, selectedDate);
|
||||
const isToday = daysAreEqual(date, today);
|
||||
const string = dateToInputString(date);
|
||||
const isCurrentMonth = date.getMonth() === monthIndex;
|
||||
days.push({
|
||||
number,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
isToday,
|
||||
string,
|
||||
isCurrentMonth,
|
||||
});
|
||||
addDays(date, 1);
|
||||
}
|
||||
return days;
|
||||
};
|
1
front/src/components/ui/calendar/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './calendar-days';
|
2
front/src/components/ui/calendar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Calendar } from './component';
|
||||
export { CalendarPreview } from './preview';
|
19
front/src/components/ui/calendar/preview.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { PreviewArticle } from '@components/ui/preview';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Calendar } from './component';
|
||||
|
||||
export function CalendarPreview() {
|
||||
const [date, setDate] = useState<string>('2024-10-09T01:07');
|
||||
|
||||
return (
|
||||
<PreviewArticle title="Calendar">
|
||||
<Calendar
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
min="2024-10-02T00:00"
|
||||
max="2024-10-15T00:00"
|
||||
/>
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
0
front/src/components/ui/calendar/styles.module.scss
Normal file
8
front/src/components/ui/calendar/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export type CalendarProps = {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
min: Date | null;
|
||||
max: Date | null;
|
||||
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
42
front/src/components/ui/checkbox-group/component.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Span } from '../span';
|
||||
import styles from './styles.module.scss';
|
||||
import { CheckboxGroupProps } from './types';
|
||||
|
||||
export function CheckboxGroup<T>({
|
||||
name,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
getItemKey,
|
||||
getItemLabel,
|
||||
scale = 'm',
|
||||
label,
|
||||
}: CheckboxGroupProps<T>) {
|
||||
const classNames = clsx(styles.checkBoxGroup, styles[scale]);
|
||||
|
||||
const handleChange = (index: number) => {
|
||||
onChange(value.with(index, !value[index]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<Span color="t300" scale={scale} className={styles.label}>
|
||||
{label}
|
||||
</Span>
|
||||
{items.map((item, index) => (
|
||||
<Checkbox
|
||||
name={name}
|
||||
label={{ text: getItemLabel(item) }}
|
||||
checked={value[index]}
|
||||
key={getItemKey(item)}
|
||||
onChange={() => handleChange(index)}
|
||||
scale={scale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
2
front/src/components/ui/checkbox-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { CheckboxGroup } from './component';
|
||||
export { CheckboxGroupPreview } from './preview';
|