Merge remote-tracking branch 'origin' into floris_design

This commit is contained in:
it-is-not-alright 2024-11-24 14:11:34 +04:00
commit 93febb40d3
259 changed files with 4340 additions and 963 deletions

View File

@ -2811,7 +2811,7 @@
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"devOptional": true
"dev": true
},
"node_modules/@types/qs": {
"version": "6.9.16",
@ -2829,7 +2829,7 @@
"version": "18.3.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
"devOptional": true,
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -4262,7 +4262,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
"dev": true
},
"node_modules/debug": {
"version": "4.3.7",

View 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

View 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

View 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

View 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

View File

@ -1,7 +0,0 @@
<?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.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
</svg>

Before

Width:  |  Height:  |  Size: 541 B

View 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
View 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' });
},
};

View File

@ -0,0 +1 @@
export const BASE_URL = 'http://localhost:8000';

View File

@ -1,2 +0,0 @@
// export const BASE_URL = 'http://localhost:8000/api';
export const BASE_URL = 'http://192.168.1.110:8000/api';

View File

@ -1 +0,0 @@
export { downloadImage, getWindmillData } from './service';

View File

@ -1,26 +0,0 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
import { BASE_URL } from './constants';
import { GetWindmillDataRes } from './types';
import { getWindmillDataParams } from './utils';
export const getWindmillData = async (store: Partial<WindmillFormStore>) => {
const params = getWindmillDataParams(store);
const url = `${BASE_URL}/floris/get_windmill_data?${params}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data: GetWindmillDataRes = await res.json();
return data;
};
export const downloadImage = async (imageName: string) => {
const url = `${BASE_URL}/floris/download_image/${imageName}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data = await res.blob();
return data;
};

View File

@ -1,4 +0,0 @@
export type GetWindmillDataRes = {
file_name: string;
data: number[];
};

View File

@ -1,9 +0,0 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
export const getWindmillDataParams = (store: Partial<WindmillFormStore>) => {
const layoutX = store.windmills?.map((row) => `layout_x=${row.x}`).join('&');
const layoutY = store.windmills?.map((row) => `layout_y=${row.y}`).join('&');
const dateStart = `date_start=${store.dateFrom?.substring(0, 10)}`;
const dateEnd = `date_end=${store.dateTo?.substring(0, 10)}`;
return `${layoutX}&${layoutY}&${dateStart}&${dateEnd}`;
};

View File

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

9
front/src/api/types.ts Normal file
View 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
View 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;
};

View 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',
};

View File

@ -0,0 +1,2 @@
export * from './service';
export * from './types';

View 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);
};

View 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[];
};

View 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 ?? '',
};
};

View 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);
}

View File

@ -0,0 +1,5 @@
@mixin on-mobile {
@media (width <= 800px) {
@content;
}
}

View File

@ -2,14 +2,12 @@
color-scheme: light;
--clr-primary: #4176FF;
--clr-primary-o50: #3865DA80;
--clr-primary-o50: #4176FF80;
--clr-primary-hover: #638FFF;
--clr-primary-active: #3D68D7;
--clr-on-primary: #FFFFFF;
--clr-secondary: #EAEAEA;
--clr-secondary-hover: #EFEFEF;
--clr-secondary-active: #E1E1E1;
--clr-secondary: #E1EAF8;
--clr-secondary-hover: #E8ECF0;
--clr-on-secondary: #0D0D0D;
--clr-layer-100: #EBEEF0;
@ -20,6 +18,7 @@
--clr-text-100: #8D8D8D;
--clr-text-200: #6C7480;
--clr-text-300: #1D1F20;
--clr-text-primary: #3865DA;
--clr-border-100: #DFDFDF;
--clr-border-200: #D8D8D8;
@ -28,6 +27,8 @@
--clr-shadow-200: #00000026;
--clr-ripple: #1D1F2026;
--clr-error: #E54B4B;
}
@mixin dark {
@ -36,12 +37,12 @@
--clr-primary: #3865DA;
--clr-primary-o50: #3865DA80;
--clr-primary-hover: #4073F7;
--clr-primary-active: #2A4DA7;
--clr-primary-disabled: #334570;
--clr-on-primary: #FFFFFF;
--clr-secondary: #3F3F3F;
--clr-secondary-hover: #4D4D4D;
--clr-secondary-active: #323232;
--clr-secondary-disabled: #323232;
--clr-on-secondary: #FFFFFF;
--clr-layer-100: #1B1B1B;
@ -52,6 +53,7 @@
--clr-text-100: #888888;
--clr-text-200: #C5C5C5;
--clr-text-300: #F0F0F0;
--clr-text-primary: #4176FF;
--clr-border-100: #3D3D3D;
--clr-border-200: #545454;
@ -60,4 +62,6 @@
--clr-shadow-200: #00000026;
--clr-ripple: #F0F0F026;
--clr-error: #FF6363;
}

View 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>
);
}

View File

@ -0,0 +1 @@
export { App } from './component';

View File

@ -1,21 +0,0 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import { HomePage } 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={<HomePage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -23,7 +23,7 @@ html[data-theme='default'] {
}
html {
--td-100: 0.2s;
--td-100: 0.1s;
}
body {

View File

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

View File

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

View 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>
);
}

View File

@ -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 className={styles.main}>
<Outlet />
</main>
</div>
);
}
export default MainLayout;
export * from './component';

View File

@ -1,13 +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);
}
}

View File

@ -1,41 +0,0 @@
import { Heading, Paragraph } from '@components/ui';
import { WindmillForm } from '@components/ux';
import { WindmillFormResponse } from '@components/ux/windmill-form';
import React, { useState } from 'react';
import styles from './styles.module.scss';
export function HomePage() {
const [result, setResult] = useState<WindmillFormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFormSuccess = (response: WindmillFormResponse) => {
setResult(response);
setError(null);
};
const handleFormFail = (message: string) => {
setError(message);
setResult(null);
};
return (
<div className={styles.page}>
<div className={styles.wrapperForm}>
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
</div>
<div className={styles.result}>
<Heading tag="h3">Result</Heading>
{result && (
<>
<div className={styles.power}>{result.power.join(' ')}</div>
<div className={styles.image}>
{result.image && <img src={result.image} alt="Image" />}
</div>
</>
)}
{error && <Paragraph>{error}</Paragraph>}
</div>
</div>
);
}

View File

@ -1 +0,0 @@
export { HomePage } from './component';

View File

@ -1,41 +0,0 @@
.page {
display: grid;
padding: 20px;
gap: 20px;
grid-template:
'. form result .' auto
/ auto minmax(0, 380px) minmax(0, 700px) auto;
}
.wrapperForm {
grid-area: form;
}
.result {
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 10px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
grid-area: result;
}
.image {
width: 100%;
img {
max-width: 100%;
border-radius: 10px;
}
}
@media (width <= 1000px) {
.page {
grid-template:
'form' auto
'result' auto
/ 1fr;
}
}

View File

@ -0,0 +1,4 @@
export * from './park-page';
export * from './parks-page';
export * from './turbine-type-page';
export * from './turbine-types-page';

View File

@ -1 +0,0 @@
export { HomePage } from './home-page';

View 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>
);
}

View File

@ -1,23 +1,28 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
& > * {
width: 100%;
}
}
.dateRangeBox {
display: flex;
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

View 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[];
};

View 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,
})),
};
};

View 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>
);
}

View 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) },
];

View 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;
}

View 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>
);
}

View File

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

View 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;
}

View File

@ -0,0 +1,5 @@
export type TurbineTypeFormValues = {
name: string;
height: string;
bladeLength: string;
};

View 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),
};
};

View 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>
);
}

View File

@ -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) },
];

View File

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

View 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;
}

View File

@ -1,5 +1,11 @@
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import React, {
CSSProperties,
ForwardedRef,
forwardRef,
useEffect,
useState,
} from 'react';
import styles from './styles.module.scss';
import { FadeProps } from './types';
@ -14,7 +20,7 @@ export function FadeInner(
}: Omit<FadeProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
const [visibleInternal, setVisibleInternal] = useState<boolean>(visible);
const classNames = clsx(
styles.fade,
@ -25,22 +31,21 @@ export function FadeInner(
const inlineStyle = {
...style,
'--animation-duration': `${duration}ms`,
} as React.CSSProperties;
} as CSSProperties;
useEffect(() => {
if (visible) {
setVisibleInner(true);
return;
setVisibleInternal(true);
}
}, [visible]);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) {
setVisibleInner(false);
setVisibleInternal(false);
}
};
if (!visibleInner) {
if (!visibleInternal) {
return null;
}

View File

@ -1,5 +1,5 @@
.fade {
animation: fadein var(--animation-duration);
animation: fadein var(--animation-duration) ease-in-out;
}
.invisible {

View File

@ -1,4 +1,6 @@
import { ComponentProps } from 'react';
export type FadeProps = {
visible: boolean;
duration?: number;
} & React.ComponentProps<'div'>;
} & ComponentProps<'div'>;

View File

@ -1,68 +1,65 @@
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import clsx from 'clsx';
import React, { useRef } from 'react';
import { RippleWave } from './parts/ripple-wave';
import styles from './styles.module.scss';
import { RippleProps } from './types';
import { calcRippleWaveStyle } from './utils';
export function RippleInner(
props: RippleProps,
ref: ForwardedRef<HTMLDivElement>,
) {
export function Ripple() {
const rippleRef = useRef<HTMLDivElement | null>(null);
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
const [isTouch, setIsTouch] = useState(false);
useImperativeHandle(ref, () => rippleRef.current, []);
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 handleWaveOnDone = () => {
setWaves((prev) => prev.slice(1));
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);
const wave = (
<RippleWave
key={new Date().getTime()}
style={style}
onDone={handleWaveOnDone}
/>
);
setWaves([...waves, wave]);
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 handleMouseDown = (event: React.MouseEvent) => {
if (isTouch) {
return;
}
const handlePointerDown = (event: React.MouseEvent) => {
event.stopPropagation();
const { pageX, pageY } = event;
addWave(pageX, pageY);
};
const handleTouchStart = (event: React.TouchEvent) => {
setIsTouch(true);
const { touches, changedTouches } = event;
const { pageX, pageY } = touches[0] ?? changedTouches[0];
addWave(pageX, pageY);
};
return (
<div
ref={rippleRef}
className={styles.ripple}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
{...props}
>
{waves}
</div>
ref={rippleRef}
onPointerDown={handlePointerDown}
/>
);
}
export const Ripple = forwardRef(RippleInner);

View File

@ -1,39 +0,0 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import styles from './style.module.scss';
import { RippleWaveProps } from './types';
export function RippleWave({ style, onDone }: RippleWaveProps) {
const [isMouseUp, setIsMouseUp] = useState(false);
const [isAnimationEnd, setIsAnimationEnd] = useState(false);
useEffect(() => {
const mouseUpListener = () => setIsMouseUp(true);
document.addEventListener('mouseup', mouseUpListener, { once: true });
document.addEventListener('touchend', mouseUpListener, { once: true });
}, []);
const visible = !isMouseUp || !isAnimationEnd;
const className = clsx(
styles.wave,
visible ? styles.visible : styles.invisible,
);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadein) {
setIsAnimationEnd(true);
} else {
onDone();
}
};
return (
<div
className={className}
onAnimationEnd={handleAnimationEnd}
style={style}
/>
);
}

View File

@ -1 +0,0 @@
export { RippleWave } from './component';

View File

@ -1,33 +0,0 @@
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
}
.visible {
animation: fadein 0.3s linear;
}
.invisible {
animation: fadeout 0.3s linear forwards;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@ -1,6 +0,0 @@
import { CSSProperties } from 'react';
export type RippleWaveProps = {
style: CSSProperties;
onDone: () => void;
};

View File

@ -5,3 +5,38 @@
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;
}
}

View File

@ -1 +0,0 @@
export type RippleProps = {} & React.ComponentProps<'div'>;

View File

@ -1,3 +1,4 @@
import { px } from '@utils/css';
import { CSSProperties } from 'react';
export const calcRippleWaveStyle = (
@ -8,7 +9,7 @@ export const calcRippleWaveStyle = (
const wrapperRect = ripple.getBoundingClientRect();
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
const radius = diameter / 2;
const left = x - wrapperRect.left - radius;
const top = y - wrapperRect.top - radius;
return { left, top, width: diameter, height: diameter };
const left = px(x - wrapperRect.left - radius);
const top = px(y - wrapperRect.top - radius);
return { left, top, width: px(diameter), height: px(diameter) };
};

View 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>;

View File

@ -0,0 +1,3 @@
export { Autocomplete } from './component';
export { AutocompletePreview } from './preview';
export { type AutocompleteProps } from './types';

View 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>
);
}

View 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);
}
}

View 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'>;

View File

@ -1,23 +1,25 @@
import clsx from 'clsx';
import React from 'react';
import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
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';
export function Button({
variant = 'primary',
scale = 'm',
pending = false,
className,
children,
disabled,
...props
}: ButtonProps) {
const classNames = clsx(
function ButtonInner(
{
variant = 'primary',
scale = 'm',
pending = false,
className,
children,
...props
}: ButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) {
const buttonClassName = clsx(
styles.button,
styles[variant],
styles[scale],
@ -25,11 +27,7 @@ export function Button({
className,
);
return (
<RawButton
className={classNames}
disabled={pending ? true : disabled}
{...props}
>
<RawButton className={buttonClassName} ref={ref} {...props}>
{pending && (
<div className={styles.cometWrapper}>
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
@ -40,3 +38,5 @@ export function Button({
</RawButton>
);
}
export const Button = forwardRef(ButtonInner);

View File

@ -1,14 +1,11 @@
@use '@components/func.scss' as f;
.button {
position: relative;
overflow: hidden;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500;
transition: all var(--td-100) ease-in-out;
&:disabled {
pointer-events: none;
}
&:not(:disabled) {
cursor: pointer;
}
@ -26,6 +23,8 @@
}
.pending {
pointer-events: none;
.childrenWrapper {
visibility: hidden;
}
@ -35,12 +34,10 @@
background-color: var(--clr-primary);
color: var(--clr-on-primary);
&:hover {
background-color: var(--clr-primary-hover);
}
&.pending {
background-color: var(--clr-primary-active);
@media (hover: hover) {
&:hover {
background-color: var(--clr-primary-hover);
}
}
}
@ -48,29 +45,31 @@
background-color: var(--clr-secondary);
color: var(--clr-on-secondary);
&:hover {
background-color: var(--clr-secondary-hover);
}
&.pending {
background-color: var(--clr-secondary-active);
@media (hover: hover) {
&:hover {
background-color: var(--clr-secondary-hover);
}
}
}
$padding: 10px 16px;
$border-radius: 8px;
$font-size: 12px;
.s {
padding: 10px 16px;
border-radius: 8px;
font-size: 12px;
padding: $padding;
border-radius: $border-radius;
font-size: $font-size;
}
.m {
padding: 14px 20px;
border-radius: 10px;
font-size: 16px;
padding: f.m($padding);
border-radius: f.m($border-radius);
font-size: f.m($font-size);
}
.l {
padding: 18px 24px;
border-radius: 12px;
font-size: 20px;
padding: f.l($padding);
border-radius: f.l($border-radius);
font-size: f.l($font-size);
}

View File

@ -1,6 +1,6 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import { CalendarDays } from './parts';
import { CalendarDays } from './components';
import { CalendarProps } from './types';
function CalendarInner(

View File

@ -33,7 +33,7 @@ export function CalendarDays({
}, [date, min, max]);
const handleChange = (newValue: string) => {
onChange?.(newValue);
onChange(newValue);
};
return (

View File

@ -40,18 +40,11 @@
justify-content: center;
border-radius: 10px;
color: var(--clr-text-100);
cursor: pointer;
transition: all var(--td-100) ease-in-out;
&:not(:disabled) {
cursor: pointer;
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
&:disabled {
color: var(--clr-text-100);
&:hover {
background-color: var(--clr-layer-300-hover);
}
}

View File

@ -9,7 +9,7 @@ export type CalendarDay = {
export type CalendarDaysProps = {
value?: string;
onChange?: (value: string) => void;
onChange: (value: string) => void;
min: Date | null;
max: Date | null;
date: Date;

View File

@ -1,11 +1,18 @@
import { dateToInputString } from '@utils/date';
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() &&

View File

@ -1,6 +1,8 @@
import { ComponentProps } from 'react';
export type CalendarProps = {
value?: string;
onChange?: (value: string) => void;
onChange: (value: string) => void;
min: Date | null;
max: Date | null;
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
} & Omit<ComponentProps<'div'>, 'onChange'>;

View File

@ -1,22 +1,26 @@
@use '@components/func.scss' as f;
.checkBoxGroup {
display: flex;
flex-direction: column;
}
$margin-bottom: 4px;
.s {
.label {
margin-bottom: 3px;
margin-bottom: $margin-bottom;
}
}
.m {
.label {
margin-bottom: 5px;
margin-bottom: f.m($margin-bottom);
}
}
.l {
.label {
margin-bottom: 7px;
margin-bottom: f.l($margin-bottom);
}
}

View File

@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation';
import { Ripple } from '../animation/ripple/component';
import { Label, LabelProps } from '../label';
import { RawInput } from '../raw';
import styles from './styles.module.scss';

View File

@ -1,8 +1,11 @@
@use '@components/func.scss' as f;
.wrapper {
position: relative;
overflow: hidden;
border-radius: 100%;
cursor: pointer;
user-select: none;
&:hover {
.checkbox {
@ -54,35 +57,40 @@
transition: all var(--td-100) ease-in-out;
}
$padding-outer: 4px;
$size: 16px;
$padding-inner: 2px;
$border-radius: 5px;
.s {
padding: 3px;
padding: $padding-outer;
.checkbox {
width: 16px;
height: 16px;
padding: 2px;
border-radius: 5px;
width: $size;
height: $size;
padding: $padding-inner;
border-radius: $border-radius;
}
}
.m {
padding: 5px;
padding: f.m($padding-outer);
.checkbox {
width: 20px;
height: 20px;
padding: 3px;
border-radius: 6px;
width: f.m($size);
height: f.m($size);
padding: f.m($padding-inner);
border-radius: f.m($border-radius);
}
}
.l {
padding: 7px;
padding: f.l($padding-outer);
.checkbox {
width: 24px;
height: 24px;
padding: 4px;
border-radius: 7px;
width: f.l($size);
height: f.l($size);
padding: f.l($padding-inner);
border-radius: f.l($border-radius);
}
}

View File

@ -1,3 +1,5 @@
@use '@components/func.scss' as f;
.comet {
border-radius: 50%;
animation: spinner-comet 1s infinite linear;
@ -9,23 +11,37 @@
}
}
$size: 12px;
$offset: 1.75px;
.s {
width: 12px;
height: 12px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
width: $size;
height: $size;
mask: radial-gradient(
farthest-side,
#0000 calc(100% - $offset),
#000 0
);
}
.m {
width: 16px;
height: 16px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
width: f.m($size);
height: f.m($size);
mask: radial-gradient(
farthest-side,
#0000 calc(100% - f.m($offset)),
#000 0
);
}
.l {
width: 20px;
height: 20px;
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
}
width: f.l($size);
height: f.l($size);
mask: radial-gradient(
farthest-side,
#0000 calc(100% - f.l($offset)),
#000 0
);}
.onPrimary {
background: conic-gradient(#0000 10%, var(--clr-on-primary));

View File

@ -1,6 +1,8 @@
import { ComponentProps } from 'react';
import { Scale } from '../types';
export type CometProps = {
scale?: Scale;
variant?: 'onPrimary' | 'onSecondary';
} & React.ComponentProps<'div'>;
} & ComponentProps<'div'>;

View File

@ -0,0 +1,77 @@
import React, { useMemo, useState } from 'react';
import { DataGridHeader, DataGridRow } from './components';
import { DataGridProps } from './types';
export function DataGrid<T>({
items,
columns,
getItemKey,
selectedItems,
onItemsSelect,
multiselect,
className,
...props
}: DataGridProps<T>) {
const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
const columnsTemplate = useMemo(() => {
const main = columns.map((c) => `${c.width ?? 1}fr`).join(' ');
return `auto ${main}`;
}, []);
const selectedItemsMap = useMemo(() => {
const map: Record<string, T> = {};
for (let i = 0; i < selectedItems.length; i += 1) {
const item = selectedItems[i];
map[String(getItemKey(item))] = item;
}
return map;
}, [selectedItems]);
const handleSelectAllItems = () => {
if (!multiselect) {
return;
}
onItemsSelect?.(allItemsSelected ? [] : [...items]);
setAllItemsSelected(!allItemsSelected);
};
const handleItemSelect = (key: string, item: T) => {
const selected = Boolean(selectedItemsMap[key]);
if (!multiselect) {
onItemsSelect?.(selected ? [] : [item]);
return;
}
onItemsSelect?.(
selected
? selectedItems.filter((i) => key !== getItemKey(i))
: [...selectedItems, item],
);
setAllItemsSelected(false);
};
return (
<div className={className} {...props}>
<DataGridHeader
columns={columns}
allItemsSelected={allItemsSelected}
onSelectAllItems={handleSelectAllItems}
columnsTemplate={columnsTemplate}
/>
{items.map((item) => {
const key = String(getItemKey(item));
return (
<DataGridRow
object={item}
columns={columns}
selected={Boolean(selectedItemsMap[key])}
onSelect={() => handleItemSelect(key, item)}
key={getItemKey(item)}
columnsTemplate={columnsTemplate}
/>
);
})}
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Ripple } from '@components/ui/animation';
import { Checkbox } from '@components/ui/checkbox';
import { RawButton } from '@components/ui/raw';
import { Span } from '@components/ui/span';
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
import clsx from 'clsx';
import React, { useState } from 'react';
import { DataGridSort } from '../../types';
import styles from './styles.module.scss';
import { DataGridHeaderProps } from './types';
export function DataGridHeader<T>({
columns,
allItemsSelected,
onSelectAllItems,
columnsTemplate,
}: DataGridHeaderProps<T>) {
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
const handleSortButtonClick = (column: string) => {
if (column === sort.column) {
if (sort.order === 'asc') {
setSort({ order: 'desc', column });
} else {
setSort({ order: 'desc', column: '' });
}
} else {
setSort({ order: 'asc', column });
}
};
return (
<header
className={styles.header}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={allItemsSelected}
onChange={onSelectAllItems}
label={{ className: styles.checkboxLabel }}
/>
{columns.map((column) => {
const isActive = sort.column === column.name;
const cellClassName = clsx(styles.cell, {
[styles.activeCell]: isActive,
[styles.desc]: isActive && sort.order === 'desc',
});
return (
<RawButton
className={cellClassName}
key={column.name}
onClick={() => handleSortButtonClick(column.name)}
>
<Span color="t300" className={styles.name}>
{column.name}
</Span>
<ArrowUpIcon className={styles.icon} />
<Ripple />
</RawButton>
);
})}
</header>
);
}

View File

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

View File

@ -0,0 +1,59 @@
.header {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
border-top-left-radius: 10px;
}
.cell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
cursor: pointer;
gap: 10px;
transition: all var(--td-100) ease-in-out;
&:last-of-type {
border-top-right-radius: 10px;
}
@media (hover: hover) {
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
}
.name {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
}
.icon {
width: 12px;
height: 12px;
flex-shrink: 0;
fill: transparent;
transition: all var(--td-100) ease-in-out;
}
.activeCell {
.icon {
fill: var(--clr-text-200);
}
}
.desc {
.icon {
rotate: 180deg;
}
}

View File

@ -0,0 +1,8 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridHeaderProps<T> = {
columns: DataGridColumnConfig<T>[];
allItemsSelected: boolean;
onSelectAllItems: () => void;
columnsTemplate: string;
};

View File

@ -0,0 +1,32 @@
import { Checkbox } from '@components/ui/checkbox';
import { Span } from '@components/ui/span';
import React from 'react';
import styles from './styles.module.scss';
import { DataGridRowProps } from './types';
export function DataGridRow<T>({
object,
columns,
selected,
onSelect,
columnsTemplate,
}: DataGridRowProps<T>) {
return (
<div
className={styles.row}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={selected}
label={{ className: styles.checkboxLabel }}
onChange={onSelect}
/>
{columns.map((column) => (
<div className={styles.cell} key={column.name}>
<Span>{column.getText(object)}</Span>
</div>
))}
</div>
);
}

View File

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

View File

@ -0,0 +1,19 @@
.row {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
}
.cell {
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,9 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridRowProps<T> = {
object: T;
columns: DataGridColumnConfig<T>[];
selected: boolean;
onSelect: () => void;
columnsTemplate: string;
};

View File

@ -0,0 +1,2 @@
export * from './DataGridHeader';
export * from './DataGridRow';

View File

@ -0,0 +1,2 @@
export * from './component';
export * from './preview';

Some files were not shown because too many files have changed in this diff Show More