[test-entity]: front
This commit is contained in:
parent
4d40a2cacb
commit
ee6bbdca8b
7
front/public/images/svg/arrow-left.svg
Normal file
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
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 |
8
front/public/images/svg/close.svg
Normal file
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 |
20
front/public/images/svg/menu.svg
Normal file
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 |
@ -1,2 +1,2 @@
|
||||
export * from './service';
|
||||
export { type Park, type TurbineType } from './types';
|
||||
export * from './types';
|
||||
|
@ -1,30 +1,38 @@
|
||||
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux';
|
||||
import { ApiResponse } from '@api/types';
|
||||
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||
|
||||
import { api } from '../api';
|
||||
import { WIND_ENDPOINTS } from './constants';
|
||||
import { Park, ParkTurbine, TurbineType } from './types';
|
||||
import { packParkFormValues, packTurbineTypeFormValues } from './utils';
|
||||
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
|
||||
import { packTurbineTypes } from './utils';
|
||||
|
||||
export const getTurbineTypes = () => {
|
||||
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
|
||||
};
|
||||
|
||||
export const createTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
|
||||
return api.post(
|
||||
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,
|
||||
packTurbineTypeFormValues(values),
|
||||
packTurbineTypes(formValues),
|
||||
);
|
||||
};
|
||||
|
||||
export const editTurbineTypes = (
|
||||
values: Partial<TurbineTypeFormValues>,
|
||||
id: number,
|
||||
formValues: Partial<TurbineTypeFormValues>,
|
||||
id: string,
|
||||
) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.put(url, packTurbineTypeFormValues(values));
|
||||
return api.put<TurbineType>(url, packTurbineTypes(formValues));
|
||||
};
|
||||
|
||||
export const deleteTurbineTypes = (id: number) => {
|
||||
export const deleteTurbineType = (id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.delete(url);
|
||||
};
|
||||
@ -33,16 +41,25 @@ export const getParks = () => {
|
||||
return api.get<Park[]>(WIND_ENDPOINTS.parks);
|
||||
};
|
||||
|
||||
export const createPark = (values: Partial<ParkFormValues>) => {
|
||||
return api.post(WIND_ENDPOINTS.park, packParkFormValues(values));
|
||||
};
|
||||
|
||||
export const editPark = (values: Partial<ParkFormValues>, id: number) => {
|
||||
export const getPark = (id: string) => {
|
||||
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||
return api.put(url, packParkFormValues(values));
|
||||
return api.get<Park>(url);
|
||||
};
|
||||
|
||||
export const getParkTurines = (id: number) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
@ -13,10 +13,20 @@ export type Park = {
|
||||
};
|
||||
|
||||
export type ParkTurbine = {
|
||||
windParkId: number;
|
||||
turbineId: number;
|
||||
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[];
|
||||
};
|
||||
|
@ -1,19 +1,9 @@
|
||||
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux';
|
||||
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||
|
||||
export const packTurbineTypeFormValues = (
|
||||
values: Partial<TurbineTypeFormValues>,
|
||||
) => {
|
||||
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
Height: parseInt(values.height ?? '0'),
|
||||
BladeLength: parseInt(values.bladeLength ?? '0'),
|
||||
};
|
||||
};
|
||||
|
||||
export const packParkFormValues = (values: Partial<ParkFormValues>) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
CenterLatitude: parseInt(values.centerLatitude ?? '0'),
|
||||
CenterLongitude: parseInt(values.centerLongitude ?? '0'),
|
||||
};
|
||||
};
|
||||
|
5
front/src/components/_mixins.scss
Normal file
5
front/src/components/_mixins.scss
Normal file
@ -0,0 +1,5 @@
|
||||
@mixin on-mobile {
|
||||
@media (width <= 800px) {
|
||||
@content;
|
||||
}
|
||||
}
|
@ -2,14 +2,12 @@
|
||||
color-scheme: light;
|
||||
|
||||
--clr-primary: #4176FF;
|
||||
--clr-primary-o50: #3865DA80;
|
||||
--clr-primary-o50: #4176FF80;
|
||||
--clr-primary-hover: #638FFF;
|
||||
--clr-primary-disabled: #3D68D7;
|
||||
--clr-on-primary: #FFFFFF;
|
||||
|
||||
--clr-secondary: #EAEAEA;
|
||||
--clr-secondary-hover: #EFEFEF;
|
||||
--clr-secondary-disabled: #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,7 +37,7 @@
|
||||
--clr-primary: #3865DA;
|
||||
--clr-primary-o50: #3865DA80;
|
||||
--clr-primary-hover: #4073F7;
|
||||
--clr-primary-disabled: #2A4DA7;
|
||||
--clr-primary-disabled: #334570;
|
||||
--clr-on-primary: #FFFFFF;
|
||||
|
||||
--clr-secondary: #3F3F3F;
|
||||
@ -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;
|
||||
}
|
||||
|
@ -2,30 +2,34 @@ import './styles.scss';
|
||||
import '@public/fonts/styles.css';
|
||||
|
||||
import { MainLayout } from '@components/layouts';
|
||||
import { ParksPage, TurbineTypesPage } from '@components/pages';
|
||||
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';
|
||||
|
||||
function App() {
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
|
||||
/>
|
||||
<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="/" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
1
front/src/components/app/index.ts
Normal file
1
front/src/components/app/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { App } from './component';
|
@ -23,7 +23,7 @@ html[data-theme='default'] {
|
||||
}
|
||||
|
||||
html {
|
||||
--td-100: 0.2s;
|
||||
--td-100: 0.1s;
|
||||
}
|
||||
|
||||
body {
|
||||
|
1
front/src/components/layouts/index.ts
Normal file
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
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,20 +1 @@
|
||||
import { Sidebar } 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}>
|
||||
<Sidebar />
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
export * from './component';
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use '@components/mixins.scss' as m;
|
||||
|
||||
.mainLayout {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
@ -6,16 +8,31 @@
|
||||
/ auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
width: 1000px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
.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,2 +1,4 @@
|
||||
export * from './park-page';
|
||||
export * from './parks-page';
|
||||
export * from './turbine-type-page';
|
||||
export * from './turbine-types-page';
|
||||
|
83
front/src/components/pages/park-page/component.tsx
Normal file
83
front/src/components/pages/park-page/component.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { getParkWithTurbines, ParkWithTurbines } from '@api/wind';
|
||||
import { Button, Heading, NumberInput, TextInput } from '@components/ui';
|
||||
import { Controller, useForm } from '@utils/form';
|
||||
import { useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ParkTurbines } from './components';
|
||||
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 params = useParams();
|
||||
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 handleReset = () => {
|
||||
if (isEdit) {
|
||||
reset(unpackPark(park));
|
||||
} else {
|
||||
reset({});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<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) => (
|
||||
<NumberInput label={{ text: 'Center Latitude' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('centerLongitude')}
|
||||
render={(props) => (
|
||||
<NumberInput 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 {...props} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
1
front/src/components/pages/park-page/components/index.ts
Normal file
1
front/src/components/pages/park-page/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './park-turbines';
|
@ -0,0 +1,19 @@
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import React from 'react';
|
||||
|
||||
import { columns } from './constants';
|
||||
import { ParkTurbinesProps } from './types';
|
||||
|
||||
export function ParkTurbines({ value = [] }: ParkTurbinesProps) {
|
||||
return (
|
||||
<div>
|
||||
<div></div>
|
||||
<DataGrid
|
||||
items={value}
|
||||
columns={columns}
|
||||
getItemKey={({ id }) => String(id)}
|
||||
selectedItems={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||
import { ParkTurbine } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<ParkTurbine>[] = [
|
||||
{ name: 'Id', getText: (t) => String(t.id) },
|
||||
{ name: 'Name', getText: (t) => t.name },
|
||||
{ name: 'X', getText: (t) => String(t.xOffset) },
|
||||
{ name: 'Y', getText: (t) => String(t.yOffset) },
|
||||
{ name: 'Angle', getText: (t) => String(t.angle) },
|
||||
{ name: 'Comment', getText: (t) => String(t.comment), flex: '2' },
|
||||
];
|
@ -0,0 +1,6 @@
|
||||
import { ParkTurbine } from '@api/wind';
|
||||
|
||||
export type ParkTurbinesProps = {
|
||||
value?: ParkTurbine[];
|
||||
onChange?: (value: ParkTurbine[]) => void;
|
||||
};
|
28
front/src/components/pages/park-page/styles.module.scss
Normal file
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;
|
||||
}
|
8
front/src/components/pages/park-page/types.ts
Normal file
8
front/src/components/pages/park-page/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ParkTurbine } from '@api/wind';
|
||||
|
||||
export type ParkFormValues = {
|
||||
name: string;
|
||||
centerLatitude: string;
|
||||
centerLongitude: string;
|
||||
turbines: ParkTurbine[];
|
||||
};
|
@ -1,11 +1,12 @@
|
||||
import { Park } from 'src/api/wind';
|
||||
import { ParkWithTurbines } from '@api/wind';
|
||||
|
||||
import { ParkFormValues } from './types';
|
||||
|
||||
export const parkToFormValues = (park: Park): ParkFormValues => {
|
||||
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
|
||||
return {
|
||||
name: park.name,
|
||||
centerLatitude: String(park.centerLatitude),
|
||||
centerLongitude: String(park.centerLongitude),
|
||||
turbines: park.turbines,
|
||||
};
|
||||
};
|
@ -1,58 +1,52 @@
|
||||
import { getParks, Park } from '@api/wind';
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { ParkModal } from '@components/ux';
|
||||
import { useRoute } from '@utils/route';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getParks, Park } from 'src/api/wind';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function ParksPage() {
|
||||
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [parks, setParks] = useState<Park[]>([]);
|
||||
const [selected, setSelected] = useState<Park>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const fetchParks = async () => {
|
||||
const res = await getParks();
|
||||
setParks(res.data ?? []);
|
||||
setParks(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchParks();
|
||||
}, []);
|
||||
|
||||
const handleCreateButtonClick = () => {
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditButtonClick = () => {
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleParkSelect = (items: Park[]) => {
|
||||
setSelected(items[0] ?? null);
|
||||
};
|
||||
|
||||
const handleDeleteButtonClick = async () => {
|
||||
//
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
{route.title}
|
||||
</Heading>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={handleCreateButtonClick}>Create new</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditButtonClick}
|
||||
disabled={!selected}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!selected}>
|
||||
<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
|
||||
@ -64,18 +58,6 @@ export function ParksPage() {
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
<ParkModal
|
||||
park={null}
|
||||
open={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSuccess={fetchParks}
|
||||
/>
|
||||
<ParkModal
|
||||
park={selected}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSuccess={fetchParks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -3,6 +3,6 @@ import { Park } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<Park>[] = [
|
||||
{ name: 'Name', getText: (t) => t.name, flex: '2' },
|
||||
{ name: 'Center latitude', getText: (t) => String(t.centerLatitude) },
|
||||
{ name: 'Center longitude', getText: (t) => String(t.centerLongitude) },
|
||||
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
|
||||
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
|
||||
];
|
||||
|
@ -1,6 +1,6 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px 20px;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
@ -13,7 +13,7 @@
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--clr-border-100);
|
||||
border-radius: 10px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
|
100
front/src/components/pages/turbine-type-page/component.tsx
Normal file
100
front/src/components/pages/turbine-type-page/component.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import {
|
||||
createTurbineTypes,
|
||||
editTurbineTypes,
|
||||
getTurbineType,
|
||||
TurbineType,
|
||||
} from '@api/wind';
|
||||
import { Button, Heading, NumberInput, 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);
|
||||
} 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) => (
|
||||
<NumberInput label={{ text: 'Height' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('bladeLength')}
|
||||
render={(props) => (
|
||||
<NumberInput 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
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
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;
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import { TurbineType } from 'src/api/wind';
|
||||
import { TurbineType } from '@api/wind';
|
||||
|
||||
import { TurbineTypeFormValues } from './types';
|
||||
|
||||
export const turbineTypeToFormValues = (
|
||||
export const unpackTurbineType = (
|
||||
turbineType: TurbineType,
|
||||
): TurbineTypeFormValues => {
|
||||
return {
|
@ -1,16 +1,14 @@
|
||||
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { TurbineTypeModal } from '@components/ux';
|
||||
import { useRoute } from '@utils/route';
|
||||
import { ROUTES, useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { deleteTurbineTypes, getTurbineTypes, TurbineType } from 'src/api/wind';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function TurbineTypesPage() {
|
||||
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
|
||||
const [selected, setSelected] = useState<TurbineType>(null);
|
||||
const route = useRoute();
|
||||
@ -24,44 +22,34 @@ export function TurbineTypesPage() {
|
||||
fetchTurbineTypes();
|
||||
}, []);
|
||||
|
||||
const handleCreateButtonClick = () => {
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditButtonClick = () => {
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteButtonClick = async () => {
|
||||
await deleteTurbineTypes(selected.id);
|
||||
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" className={styles.heading}>
|
||||
{route.title}
|
||||
</Heading>
|
||||
<Heading tag="h1">{route.title}</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={handleCreateButtonClick}>Create new</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditButtonClick}
|
||||
disabled={!selected}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDeleteButtonClick}
|
||||
disabled={!selected}
|
||||
<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
|
||||
@ -73,18 +61,6 @@ export function TurbineTypesPage() {
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
<TurbineTypeModal
|
||||
turbineType={null}
|
||||
open={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSuccess={fetchTurbineTypes}
|
||||
/>
|
||||
<TurbineTypeModal
|
||||
turbineType={selected}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSuccess={fetchTurbineTypes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px 20px;
|
||||
padding: 40px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
@ -13,7 +13,7 @@
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--clr-border-100);
|
||||
border-radius: 10px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
.fade {
|
||||
animation: fadein var(--animation-duration);
|
||||
animation: fadein var(--animation-duration) ease-in-out;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
@ -9,17 +9,21 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export type FadeProps = {
|
||||
visible: boolean;
|
||||
duration?: number;
|
||||
} & React.ComponentProps<'div'>;
|
||||
} & ComponentProps<'div'>;
|
||||
|
@ -1,3 +1,2 @@
|
||||
export * from './fade';
|
||||
export * from './ripple';
|
||||
export * from './slide';
|
||||
|
@ -50,6 +50,7 @@ export function Ripple() {
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const { pageX, pageY } = event;
|
||||
addWave(pageX, pageY);
|
||||
};
|
||||
|
@ -10,14 +10,15 @@
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background-color: var(--clr-ripple);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
animation: fadein 0.3s linear;
|
||||
animation: fadein 0.25s linear;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
animation: fadeout 0.3s linear forwards;
|
||||
animation: fadeout 0.25s linear;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
|
@ -1,58 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { SlideProps } from './types';
|
||||
|
||||
export function SlideInner(
|
||||
{
|
||||
visible,
|
||||
duration = 200,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: Omit<SlideProps, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
|
||||
|
||||
const classNames = clsx(
|
||||
styles.slide,
|
||||
{ [styles.invisible]: !visible },
|
||||
className,
|
||||
);
|
||||
|
||||
const inlineStyle = {
|
||||
...style,
|
||||
'--animation-duration': `${duration}ms`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setVisibleInner(true);
|
||||
return;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
||||
if (event.animationName === styles.fadeout) {
|
||||
setVisibleInner(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visibleInner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
ref={ref}
|
||||
style={inlineStyle}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Slide = forwardRef(SlideInner);
|
@ -1,29 +0,0 @@
|
||||
.slide {
|
||||
animation: fadein var(--animation-duration);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export type SlideProps = {
|
||||
visible: boolean;
|
||||
duration?: number;
|
||||
} & React.ComponentProps<'div'>;
|
@ -26,8 +26,6 @@ function AutocompleteInner<T>(
|
||||
label = {},
|
||||
name,
|
||||
id,
|
||||
className,
|
||||
...props
|
||||
}: Omit<AutocompleteProps<T>, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
@ -39,18 +37,15 @@ function AutocompleteInner<T>(
|
||||
|
||||
useImperativeHandle(ref, () => autocompleteRef.current, []);
|
||||
|
||||
useMissClick(
|
||||
[autocompleteRef, menuRef],
|
||||
() => setMenuVisible(false),
|
||||
menuVisible,
|
||||
);
|
||||
useMissClick({
|
||||
callback: () => setMenuVisible(false),
|
||||
enabled: menuVisible,
|
||||
whitelist: [autocompleteRef, menuRef],
|
||||
});
|
||||
|
||||
const autocompleteClassName = clsx(
|
||||
styles.autocomplete,
|
||||
styles[scale],
|
||||
{ [styles.menuVisible]: menuVisible },
|
||||
className,
|
||||
);
|
||||
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
|
||||
[styles.menuVisible]: menuVisible,
|
||||
});
|
||||
|
||||
const filteredOptions = options.filter((option) => {
|
||||
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||
@ -80,7 +75,7 @@ function AutocompleteInner<T>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={autocompleteClassName} ref={autocompleteRef} {...props}>
|
||||
<div className={autocompleteClassName} ref={autocompleteRef}>
|
||||
<TextInput
|
||||
value={value ? getOptionLabel(value) : text}
|
||||
onClick={handleInputClick}
|
||||
|
@ -1,14 +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) => React.Key;
|
||||
getOptionKey: (option: T) => Key;
|
||||
getOptionLabel: (option: T) => string;
|
||||
onChange?: (option: T) => void;
|
||||
scale?: Scale;
|
||||
label?: LabelProps;
|
||||
name?: string;
|
||||
id?: string;
|
||||
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
||||
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { Ripple } from '../animation/ripple/component';
|
||||
import { Comet } from '../comet';
|
||||
@ -8,16 +8,18 @@ import { COMET_VARIANT_MAP } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
import { ButtonProps } from './types.js';
|
||||
|
||||
export function Button({
|
||||
function ButtonInner(
|
||||
{
|
||||
variant = 'primary',
|
||||
scale = 'm',
|
||||
pending = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classNames = clsx(
|
||||
}: 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);
|
||||
|
@ -3,14 +3,9 @@
|
||||
.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;
|
||||
}
|
||||
@ -28,6 +23,8 @@
|
||||
}
|
||||
|
||||
.pending {
|
||||
pointer-events: none;
|
||||
|
||||
.childrenWrapper {
|
||||
visibility: hidden;
|
||||
}
|
||||
@ -37,13 +34,10 @@
|
||||
background-color: var(--clr-primary);
|
||||
color: var(--clr-on-primary);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.pending {
|
||||
background-color: var(--clr-primary-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,13 +45,10 @@
|
||||
background-color: var(--clr-secondary);
|
||||
color: var(--clr-on-secondary);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-secondary-hover);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.pending {
|
||||
background-color: var(--clr-secondary-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export type CalendarProps = {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
min: Date | null;
|
||||
max: Date | null;
|
||||
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
||||
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
||||
|
@ -45,7 +45,7 @@
|
||||
.checkbox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--clr-border-200);
|
||||
border: 1px solid var(--clr-border-200);
|
||||
background-color: var(--clr-layer-300);
|
||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
@ -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'>;
|
||||
|
@ -1,71 +1,67 @@
|
||||
import { arrayToObject } from '@utils/array';
|
||||
import clsx from 'clsx';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { DataGridHeader, DataGridRow } from './components';
|
||||
import styles from './styles.module.scss';
|
||||
import { DataGridProps } from './types';
|
||||
|
||||
export function DataGrid<T>({
|
||||
items,
|
||||
columns,
|
||||
getItemKey,
|
||||
className,
|
||||
selectedItems,
|
||||
onItemsSelect,
|
||||
multiselect = true,
|
||||
multiselect,
|
||||
className,
|
||||
...props
|
||||
}: DataGridProps<T>) {
|
||||
const [allRowsSelected, setAllRowsSelected] = useState<boolean>(false);
|
||||
const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
|
||||
|
||||
const selectedItemsMap = useMemo(
|
||||
() => arrayToObject(selectedItems, (i) => getItemKey(i)),
|
||||
[selectedItems],
|
||||
);
|
||||
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 handleSelectAllRows = () => {
|
||||
const handleSelectAllItems = () => {
|
||||
if (!multiselect) {
|
||||
return;
|
||||
}
|
||||
setAllRowsSelected(!allRowsSelected);
|
||||
if (allRowsSelected) {
|
||||
onItemsSelect([]);
|
||||
} else {
|
||||
onItemsSelect([...items]);
|
||||
}
|
||||
onItemsSelect?.(allItemsSelected ? [] : [...items]);
|
||||
setAllItemsSelected(!allItemsSelected);
|
||||
};
|
||||
|
||||
const handleRowSelect = (item: T) => {
|
||||
setAllRowsSelected(false);
|
||||
const key = getItemKey(item);
|
||||
const selected = selectedItemsMap[key];
|
||||
const handleItemSelect = (key: string, item: T) => {
|
||||
const selected = Boolean(selectedItemsMap[key]);
|
||||
if (!multiselect) {
|
||||
onItemsSelect(selected ? [] : [item]);
|
||||
} else {
|
||||
onItemsSelect(
|
||||
return;
|
||||
}
|
||||
onItemsSelect?.(
|
||||
selected
|
||||
? selectedItems.filter((i) => key !== getItemKey(i))
|
||||
: [...selectedItems, item],
|
||||
);
|
||||
}
|
||||
setAllItemsSelected(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.dataGrid, className)} {...props}>
|
||||
<div className={className} {...props}>
|
||||
<DataGridHeader
|
||||
columns={columns}
|
||||
allRowsSelected={allRowsSelected}
|
||||
onSelectAllRows={handleSelectAllRows}
|
||||
allItemsSelected={allItemsSelected}
|
||||
onSelectAllItems={handleSelectAllItems}
|
||||
/>
|
||||
{items.map((item) => {
|
||||
const key = getItemKey(item);
|
||||
const key = String(getItemKey(item));
|
||||
return (
|
||||
<DataGridRow
|
||||
object={item}
|
||||
columns={columns}
|
||||
selected={selectedItemsMap[key] ? true : false}
|
||||
onSelect={() => handleRowSelect(item)}
|
||||
key={key}
|
||||
selected={Boolean(selectedItemsMap[key])}
|
||||
onSelect={() => handleItemSelect(key, item)}
|
||||
key={getItemKey(item)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -12,8 +12,8 @@ import { DataGridHeaderProps } from './types';
|
||||
|
||||
export function DataGridHeader<T>({
|
||||
columns,
|
||||
allRowsSelected,
|
||||
onSelectAllRows,
|
||||
allItemsSelected,
|
||||
onSelectAllItems,
|
||||
}: DataGridHeaderProps<T>) {
|
||||
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
|
||||
|
||||
@ -32,8 +32,8 @@ export function DataGridHeader<T>({
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Checkbox
|
||||
checked={allRowsSelected}
|
||||
onChange={onSelectAllRows}
|
||||
checked={allItemsSelected}
|
||||
onChange={onSelectAllItems}
|
||||
label={{ className: styles.checkboxLabel }}
|
||||
/>
|
||||
{columns.map((column) => {
|
||||
|
@ -6,6 +6,7 @@
|
||||
padding: 10px;
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-300);
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
@ -18,13 +19,18 @@
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-300);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
|
@ -2,6 +2,6 @@ import { DataGridColumnConfig } from '../../types';
|
||||
|
||||
export type DataGridHeaderProps<T> = {
|
||||
columns: DataGridColumnConfig<T>[];
|
||||
allRowsSelected: boolean;
|
||||
onSelectAllRows: () => void;
|
||||
allItemsSelected: boolean;
|
||||
onSelectAllItems: () => void;
|
||||
};
|
||||
|
@ -24,7 +24,7 @@ export function DataGridRow<T>({
|
||||
style={{ flex: column.flex }}
|
||||
key={column.name}
|
||||
>
|
||||
<Span color="t200">{column.getText(object)}</Span>
|
||||
<Span>{column.getText(object)}</Span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,10 +1,19 @@
|
||||
import { PreviewArticle } from '@components/ui/preview';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { DataGrid } from './component';
|
||||
import { Cat, DataGridColumnConfig } from './types';
|
||||
import { DataGridColumnConfig } from './types';
|
||||
|
||||
type Cat = {
|
||||
name: string;
|
||||
breed: string;
|
||||
age: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function DataGridPreview() {
|
||||
const [selectedItems, setSelectedItems] = useState<Cat[]>([]);
|
||||
|
||||
const items: Cat[] = [
|
||||
{ name: 'Luna', breed: 'British Shorthair', color: 'Gray', age: '2' },
|
||||
{ name: 'Simba', breed: 'Siamese', color: 'Cream', age: '1' },
|
||||
@ -14,15 +23,24 @@ export function DataGridPreview() {
|
||||
];
|
||||
|
||||
const columns: DataGridColumnConfig<Cat>[] = [
|
||||
{ name: 'Name', getText: (cat) => cat.name, flex: '2' },
|
||||
{ name: 'Breed', getText: (cat) => cat.breed },
|
||||
{ name: 'Name', getText: (cat) => cat.name },
|
||||
{ name: 'Breed', getText: (cat) => cat.breed, scale: 2 },
|
||||
{ name: 'Age', getText: (cat) => cat.age },
|
||||
{ name: 'Color', getText: (cat) => cat.color },
|
||||
];
|
||||
|
||||
return (
|
||||
<PreviewArticle title="DataGrid">
|
||||
<DataGrid style={{ width: '100%' }} items={items} columns={columns} />
|
||||
<div style={{ width: '100%', overflow: 'auto' }}>
|
||||
<DataGrid
|
||||
style={{ minWidth: 500 }}
|
||||
items={items}
|
||||
columns={columns}
|
||||
getItemKey={({ name }) => name}
|
||||
selectedItems={selectedItems}
|
||||
onItemsSelect={setSelectedItems}
|
||||
/>
|
||||
</div>
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +0,0 @@
|
||||
.dataGrid {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { ComponentPropsWithoutRef, Key } from 'react';
|
||||
|
||||
export type DataGridColumnConfig<T> = {
|
||||
name: string;
|
||||
getText: (object: T) => string;
|
||||
@ -13,15 +15,8 @@ export type DataGridSort = {
|
||||
export type DataGridProps<T> = {
|
||||
items: T[];
|
||||
columns: DataGridColumnConfig<T>[];
|
||||
getItemKey: (object: T) => string;
|
||||
selectedItems: T[];
|
||||
onItemsSelect: (selectedItems: T[]) => void;
|
||||
getItemKey: (item: T) => Key;
|
||||
selectedItems?: T[];
|
||||
onItemsSelect?: (selectedItems: T[]) => void;
|
||||
multiselect?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
export type Cat = {
|
||||
name: string;
|
||||
breed: string;
|
||||
age: string;
|
||||
color: string;
|
||||
};
|
||||
} & ComponentPropsWithoutRef<'div'>;
|
||||
|
@ -37,11 +37,11 @@ export function DateInput({
|
||||
setDirtyDate(valueToDirtyDate(value));
|
||||
}, [value]);
|
||||
|
||||
useMissClick(
|
||||
[wrapperRef, calendarWrapperRef],
|
||||
() => setCalendarVisible(false),
|
||||
calendarVisible,
|
||||
);
|
||||
useMissClick({
|
||||
callback: () => setCalendarVisible(false),
|
||||
enabled: calendarVisible,
|
||||
whitelist: [wrapperRef, calendarWrapperRef],
|
||||
});
|
||||
|
||||
const handleCalendarButtonClick = () => {
|
||||
setCalendarVisible(!calendarVisible);
|
||||
|
43
front/src/components/ui/dialog/component.tsx
Normal file
43
front/src/components/ui/dialog/component.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useMissClick } from '@utils/miss-click';
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Fade } from '../animation';
|
||||
import { Heading } from '../heading';
|
||||
import { Overlay } from '../overlay';
|
||||
import { Paragraph } from '../paragraph';
|
||||
import styles from './styles.module.scss';
|
||||
import { DialogProps } from './types';
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
heading,
|
||||
message,
|
||||
triggerElementRef,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useMissClick({
|
||||
callback: onClose,
|
||||
enabled: open,
|
||||
whitelist: [dialogRef, triggerElementRef],
|
||||
});
|
||||
|
||||
const dialogClassName = clsx(styles.dialog, className);
|
||||
|
||||
return (
|
||||
<Overlay open={open} className={styles.overlay}>
|
||||
<Fade visible={open} className={styles.fade}>
|
||||
<div className={dialogClassName} ref={dialogRef} {...props}>
|
||||
<Heading tag="h3">{heading}</Heading>
|
||||
<Paragraph color="t300">{message}</Paragraph>
|
||||
{children}
|
||||
</div>
|
||||
</Fade>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
2
front/src/components/ui/dialog/index.ts
Normal file
2
front/src/components/ui/dialog/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './component';
|
||||
export * from './preview';
|
32
front/src/components/ui/dialog/preview.tsx
Normal file
32
front/src/components/ui/dialog/preview.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { PreviewArticle } from '@components/ui/preview';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { Dialog } from './component';
|
||||
|
||||
export function DialogPreview() {
|
||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<PreviewArticle title="Dialog">
|
||||
<Button onClick={() => setOpen(true)} ref={openButtonRef}>
|
||||
Open dialog
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
heading="Dialog"
|
||||
message="Short dialog message"
|
||||
triggerElementRef={openButtonRef}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Confirm</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
26
front/src/components/ui/dialog/styles.module.scss
Normal file
26
front/src/components/ui/dialog/styles.module.scss
Normal file
@ -0,0 +1,26 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.overlay {
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
grid-template:
|
||||
'. . .' 1fr
|
||||
'. fade .' auto
|
||||
'. . .' 2fr
|
||||
/ 1fr auto 1fr;
|
||||
}
|
||||
|
||||
.fade {
|
||||
grid-area: fade;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
background-color: var(--clr-layer-300);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
}
|
9
front/src/components/ui/dialog/types.ts
Normal file
9
front/src/components/ui/dialog/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ComponentPropsWithoutRef, MutableRefObject } from 'react';
|
||||
|
||||
export type DialogProps = {
|
||||
heading: string;
|
||||
message: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
triggerElementRef?: MutableRefObject<HTMLElement>;
|
||||
} & ComponentPropsWithoutRef<'div'>;
|
@ -1,3 +1,5 @@
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { LabelProps } from '../label';
|
||||
import { RawInputProps } from '../raw';
|
||||
import { Scale } from '../types';
|
||||
@ -8,4 +10,4 @@ export type FileUploaderProps = {
|
||||
scale?: Scale;
|
||||
label?: LabelProps;
|
||||
input?: Omit<RawInputProps, 'type'>;
|
||||
} & Omit<React.ComponentPropsWithoutRef<'button'>, 'onChange'>;
|
||||
} & Omit<ComponentPropsWithoutRef<'button'>, 'onChange'>;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { TextColor } from '../types';
|
||||
|
||||
export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
@ -5,4 +7,4 @@ export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
export type HeadingProps<T extends HeadingTag> = {
|
||||
tag: T;
|
||||
color?: TextColor;
|
||||
} & React.ComponentProps<T>;
|
||||
} & ComponentProps<T>;
|
||||
|
@ -1,22 +1,33 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { Ripple } from '../animation';
|
||||
import { RawButton } from '../raw';
|
||||
import styles from './styles.module.scss';
|
||||
import { IconButtonProps } from './types';
|
||||
|
||||
export function IconButton({
|
||||
function IconButtonInner(
|
||||
{
|
||||
variant = 'circle',
|
||||
scale = 'm',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const classes = clsx(styles.button, styles[scale], className);
|
||||
}: IconButtonProps,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const buttonClassName = clsx(
|
||||
styles.button,
|
||||
styles[scale],
|
||||
styles[variant],
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<RawButton className={classes} {...props}>
|
||||
<RawButton className={buttonClassName} ref={ref} {...props}>
|
||||
{children}
|
||||
<Ripple />
|
||||
</RawButton>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef(IconButtonInner);
|
||||
|
@ -3,44 +3,75 @@
|
||||
.button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-ripple);
|
||||
|
||||
svg {
|
||||
fill: var(--clr-text-300);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--clr-text-100);
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-ripple);
|
||||
|
||||
svg {
|
||||
fill: var(--clr-text-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
$size: 26px;
|
||||
$padding: 4px;
|
||||
$rect-padding: 6px;
|
||||
$circle-padding: 4px;
|
||||
$border-radius: 8px;
|
||||
|
||||
.s {
|
||||
width: $size;
|
||||
height: $size;
|
||||
padding: $padding;
|
||||
|
||||
&.rect {
|
||||
padding: $rect-padding;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
&.circle {
|
||||
padding: $circle-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.m {
|
||||
width: f.m($size);
|
||||
height: f.m($size);
|
||||
padding: f.m($padding);
|
||||
|
||||
&.rect {
|
||||
padding: f.m($rect-padding);
|
||||
border-radius: f.m($border-radius);
|
||||
}
|
||||
|
||||
&.circle {
|
||||
padding: f.m($circle-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.l {
|
||||
width: f.l($size);
|
||||
height: f.l($size);
|
||||
padding: f.l($padding);
|
||||
|
||||
&.rect {
|
||||
padding: f.l($rect-padding);
|
||||
border-radius: f.l($border-radius);
|
||||
}
|
||||
|
||||
&.circle {
|
||||
padding: f.l($circle-padding);
|
||||
}
|
||||
}
|
||||
|
@ -3,5 +3,6 @@ import { Scale } from '@components/ui/types';
|
||||
import { RawButtonProps } from '../raw';
|
||||
|
||||
export type IconButtonProps = {
|
||||
variant?: 'circle' | 'rect';
|
||||
scale?: Scale;
|
||||
} & RawButtonProps;
|
||||
|
@ -6,4 +6,4 @@ export type ImageFileManagerProps = {
|
||||
onChange?: (value: File | null) => void;
|
||||
scale?: Scale;
|
||||
label?: LabelProps;
|
||||
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>;
|
||||
} & Omit<ComponentPropsWithoutRef<'div'>, 'onChange'>;
|
||||
|
@ -7,9 +7,12 @@ export { FileUploader } from './file-uploader';
|
||||
export { Heading } from './heading';
|
||||
export { IconButton } from './icon-button';
|
||||
export { ImageFileManager } from './image-file-manager';
|
||||
export { LinkButton } from './link-button';
|
||||
export { Menu } from './menu';
|
||||
export { Dialog } from './dialog';
|
||||
export { NumberInput } from './number-input';
|
||||
export { Overlay } from './overlay';
|
||||
export { Pagination } from './pagination';
|
||||
export { Paragraph } from './paragraph';
|
||||
export { PasswordInput } from './password-input';
|
||||
export { RadioGroup } from './radio-group';
|
@ -11,6 +11,7 @@ function InputInner(
|
||||
wrapper = {},
|
||||
leftNode,
|
||||
rightNode,
|
||||
invalid,
|
||||
className,
|
||||
onFocus,
|
||||
onBlur,
|
||||
@ -24,6 +25,7 @@ function InputInner(
|
||||
styles.wrapper,
|
||||
focus && styles.wrapperFocus,
|
||||
wrapper.className,
|
||||
invalid && styles.invalid,
|
||||
);
|
||||
const inputClassNames = clsx(styles.input, className);
|
||||
|
||||
|
@ -14,6 +14,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.invalid {
|
||||
border-color: var(--clr-error);
|
||||
}
|
||||
|
||||
.wrapperFocus {
|
||||
z-index: 1;
|
||||
border-color: var(--clr-primary);
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Scale } from '@components/ui/types';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
|
||||
import { RawInputProps } from '../raw';
|
||||
|
||||
type InputProps = {
|
||||
scale?: Scale;
|
||||
wrapper?: React.ComponentProps<'div'>;
|
||||
leftNode?: React.ReactNode;
|
||||
rightNode?: React.ReactNode;
|
||||
wrapper?: ComponentProps<'div'>;
|
||||
leftNode?: ReactNode;
|
||||
rightNode?: ReactNode;
|
||||
invalid?: boolean;
|
||||
} & RawInputProps;
|
||||
|
||||
export { InputProps };
|
||||
|
@ -11,6 +11,7 @@ function LabelInner(
|
||||
scale,
|
||||
position = 'top',
|
||||
required = {},
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
@ -29,6 +30,11 @@ function LabelInner(
|
||||
</Span>
|
||||
)}
|
||||
{!reversed && children}
|
||||
{error && (
|
||||
<Span scale={scale} className={styles.error}>
|
||||
{error}
|
||||
</Span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
.label {
|
||||
display: flex;
|
||||
|
||||
.error {
|
||||
color: var(--clr-error);
|
||||
}
|
||||
}
|
||||
|
||||
.left,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { Required, Scale } from '../types';
|
||||
|
||||
export type LabelProps = {
|
||||
@ -5,4 +7,5 @@ export type LabelProps = {
|
||||
scale?: Scale;
|
||||
position?: 'left' | 'top' | 'right' | 'bottom';
|
||||
required?: Required;
|
||||
} & React.ComponentProps<'label'>;
|
||||
error?: string;
|
||||
} & ComponentProps<'label'>;
|
||||
|
23
front/src/components/ui/link-button/component.tsx
Normal file
23
front/src/components/ui/link-button/component.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Ripple } from '../animation';
|
||||
import styles from './styles.module.scss';
|
||||
import { LinkButtonProps } from './types';
|
||||
|
||||
export function LinkButton({
|
||||
scale = 'm',
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: LinkButtonProps) {
|
||||
const linkClassName = clsx(styles.button, styles[scale], className);
|
||||
return (
|
||||
<Link className={linkClassName} to={href} {...props}>
|
||||
{children}
|
||||
<Ripple />
|
||||
</Link>
|
||||
);
|
||||
}
|
1
front/src/components/ui/link-button/index.tsx
Normal file
1
front/src/components/ui/link-button/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
37
front/src/components/ui/link-button/styles.module.scss
Normal file
37
front/src/components/ui/link-button/styles.module.scss
Normal file
@ -0,0 +1,37 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--clr-text-primary);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
$padding: 10px 16px;
|
||||
$border-radius: 8px;
|
||||
$font-size: 12px;
|
||||
|
||||
.s {
|
||||
padding: $padding;
|
||||
border-radius: $border-radius;
|
||||
font-size: $font-size;
|
||||
}
|
||||
|
||||
.m {
|
||||
padding: f.m($padding);
|
||||
border-radius: f.m($border-radius);
|
||||
font-size: f.m($font-size);
|
||||
}
|
||||
|
||||
.l {
|
||||
padding: f.l($padding);
|
||||
border-radius: f.l($border-radius);
|
||||
font-size: f.l($font-size);
|
||||
}
|
7
front/src/components/ui/link-button/types.ts
Normal file
7
front/src/components/ui/link-button/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { Scale } from '../types';
|
||||
|
||||
export type LinkButtonProps = {
|
||||
scale?: Scale;
|
||||
} & ComponentPropsWithoutRef<'a'>;
|
@ -1,7 +1,9 @@
|
||||
import { ComponentProps, Key } from 'react';
|
||||
|
||||
export type MenuProps<T> = {
|
||||
options: T[];
|
||||
selected?: T;
|
||||
getOptionKey: (option: T) => React.Key;
|
||||
getOptionKey: (option: T) => Key;
|
||||
getOptionLabel: (option: T) => string;
|
||||
onSelect?: (option: T) => void;
|
||||
} & Omit<React.ComponentProps<'ul'>, 'onSelect'>;
|
||||
} & Omit<ComponentProps<'ul'>, 'onSelect'>;
|
||||
|
@ -1,16 +1,47 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Fade } from '../animation';
|
||||
import styles from './styles.module.scss';
|
||||
import { OverlayProps } from './types';
|
||||
|
||||
export function Overlay({ open, children, variant = 'small' }: OverlayProps) {
|
||||
function OverlayInner(
|
||||
{ open, className, ...props }: OverlayProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [openInternal, setOpenInternal] = useState<boolean>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setOpenInternal(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const overlayClassName = clsx(
|
||||
styles.overlay,
|
||||
{ [styles.closed]: !open },
|
||||
className,
|
||||
);
|
||||
|
||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
||||
if (event.animationName === styles.fadeout) {
|
||||
setOpenInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!openInternal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Fade visible={open}>
|
||||
<div className={clsx(styles.backdrop, styles[variant])}>{children}</div>
|
||||
</Fade>,
|
||||
<div
|
||||
className={overlayClassName}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export const Overlay = forwardRef(OverlayInner);
|
||||
|
@ -1,27 +1,30 @@
|
||||
.backdrop {
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: rgba(0 0 0 / 0.75);
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
animation: fadein 0.25s forwards ease-in-out;
|
||||
}
|
||||
|
||||
.small {
|
||||
grid-template:
|
||||
'. . .' 1fr
|
||||
'. form .' auto
|
||||
'. . .' 5fr
|
||||
/ 1fr minmax(0, 400px) 1fr;
|
||||
.closed {
|
||||
animation: fadeout 0.25s forwards ease-in-out;
|
||||
}
|
||||
|
||||
.large {
|
||||
grid-template:
|
||||
'. . .' 1fr
|
||||
'. form .' auto
|
||||
'. . .' 5fr
|
||||
/ 1fr minmax(0, 600px) 1fr;
|
||||
@keyframes fadein {
|
||||
from {
|
||||
background-color: rgba(0 0 0 / 0);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(0 0 0 / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
background-color: rgba(0 0 0 / 0.5);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(0 0 0 / 0);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type OverlayProps = {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
variant?: 'small' | 'large';
|
||||
};
|
||||
} & ComponentPropsWithoutRef<'div'>;
|
||||
|
71
front/src/components/ui/pagination/component.tsx
Normal file
71
front/src/components/ui/pagination/component.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import ArrowLeftIcon from '@public/images/svg/arrow-left.svg';
|
||||
import ArrowRightIcon from '@public/images/svg/arrow-right.svg';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { PaginationItem } from './components';
|
||||
import styles from './styles.module.scss';
|
||||
import { PaginationProps } from './types';
|
||||
import { getPageNumbers } from './utils';
|
||||
|
||||
export function Pagination({
|
||||
value,
|
||||
onChange,
|
||||
total,
|
||||
sibling = 2,
|
||||
boundary = 1,
|
||||
scale = 'm',
|
||||
}: PaginationProps) {
|
||||
const pageNumbers = getPageNumbers(value, total, sibling, boundary);
|
||||
const paginationClassNames = clsx(styles.pagination, styles[scale]);
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
onChange?.(Math.max(value - 1, 1));
|
||||
};
|
||||
|
||||
const handleNextButtonClick = () => {
|
||||
onChange?.(Math.min(value + 1, total));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={paginationClassNames}>
|
||||
<PaginationItem
|
||||
scale={scale}
|
||||
variant="default"
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={value === 1}
|
||||
>
|
||||
<ArrowLeftIcon className={styles.icon} />
|
||||
</PaginationItem>
|
||||
{pageNumbers.map((number, index) => {
|
||||
if (number === null) {
|
||||
return (
|
||||
<PaginationItem scale={scale} variant="dots" disabled key={index}>
|
||||
...
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
const isCurrent = number === value;
|
||||
return (
|
||||
<PaginationItem
|
||||
scale={scale}
|
||||
variant={isCurrent ? 'current' : 'default'}
|
||||
disabled={isCurrent}
|
||||
key={index}
|
||||
onClick={() => onChange?.(number)}
|
||||
>
|
||||
{number}
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
<PaginationItem
|
||||
scale={scale}
|
||||
variant="default"
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={value === total}
|
||||
>
|
||||
<ArrowRightIcon className={styles.icon} />
|
||||
</PaginationItem>
|
||||
</div>
|
||||
);
|
||||
}
|
1
front/src/components/ui/pagination/components/index.ts
Normal file
1
front/src/components/ui/pagination/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './pagination-item';
|
@ -0,0 +1,28 @@
|
||||
import { Ripple } from '@components/ui/animation';
|
||||
import { RawButton } from '@components/ui/raw';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { PaginationItemProps } from './types';
|
||||
|
||||
export function PaginationItem({
|
||||
scale,
|
||||
variant,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PaginationItemProps) {
|
||||
const itemClassName = clsx(
|
||||
styles.item,
|
||||
styles[variant],
|
||||
styles[scale],
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<RawButton className={itemClassName} {...props}>
|
||||
{children}
|
||||
<Ripple />
|
||||
</RawButton>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './component';
|
@ -0,0 +1,69 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: var(--clr-primary);
|
||||
color: var(--clr-on-primary);
|
||||
}
|
||||
|
||||
.dots {
|
||||
color: var(--clr-on-secondary);
|
||||
}
|
||||
|
||||
.default {
|
||||
background-color: var(--clr-secondary);
|
||||
color: var(--clr-on-secondary);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--clr-secondary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$size: 30px;
|
||||
$border-radius: 8px;
|
||||
$font-size: 12px;
|
||||
|
||||
.s {
|
||||
min-width: $size;
|
||||
height: $size;
|
||||
border-radius: $border-radius;
|
||||
font-size: $font-size;
|
||||
|
||||
svg {
|
||||
height: $font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.m {
|
||||
min-width: f.m($size);
|
||||
height: f.m($size);
|
||||
border-radius: f.m($border-radius);
|
||||
font-size: f.m($font-size);
|
||||
|
||||
svg {
|
||||
height: f.m($font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.l {
|
||||
min-width: f.l($size);
|
||||
height: f.l($size);
|
||||
border-radius: f.l($border-radius);
|
||||
font-size: f.l($font-size);
|
||||
|
||||
svg {
|
||||
height: f.l($font-size);
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { Scale } from '@components/ui/types';
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type PaginationItemProps = {
|
||||
scale: Scale;
|
||||
variant: 'current' | 'dots' | 'default';
|
||||
} & ComponentPropsWithoutRef<'button'>;
|
2
front/src/components/ui/pagination/index.ts
Normal file
2
front/src/components/ui/pagination/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Pagination } from './component';
|
||||
export { PaginationPreview } from './preview';
|
18
front/src/components/ui/pagination/preview.tsx
Normal file
18
front/src/components/ui/pagination/preview.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { PreviewArticle } from '@components/ui/preview';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Pagination } from './component';
|
||||
|
||||
export function PaginationPreview() {
|
||||
const [value1, setValue1] = useState<number>(1);
|
||||
const [value2, setValue2] = useState<number>(1);
|
||||
const [value3, setValue3] = useState<number>(1);
|
||||
|
||||
return (
|
||||
<PreviewArticle title="Pagination">
|
||||
<Pagination value={value1} onChange={setValue1} total={20} scale="s" />
|
||||
<Pagination value={value2} onChange={setValue2} total={20} scale="m" />
|
||||
<Pagination value={value3} onChange={setValue3} total={20} scale="l" />
|
||||
</PreviewArticle>
|
||||
);
|
||||
}
|
25
front/src/components/ui/pagination/styles.module.scss
Normal file
25
front/src/components/ui/pagination/styles.module.scss
Normal file
@ -0,0 +1,25 @@
|
||||
@use '@components/func.scss' as f;
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--clr-on-secondary);
|
||||
}
|
||||
|
||||
$gap: 5px;
|
||||
|
||||
.s {
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
.m {
|
||||
gap: f.m($gap);
|
||||
}
|
||||
|
||||
.l {
|
||||
gap: f.l($gap);
|
||||
}
|
10
front/src/components/ui/pagination/types.ts
Normal file
10
front/src/components/ui/pagination/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Scale } from '../types';
|
||||
|
||||
export type PaginationProps = {
|
||||
value: number;
|
||||
onChange?: (value: number) => void;
|
||||
total: number;
|
||||
sibling?: number;
|
||||
boundary?: number;
|
||||
scale?: Scale;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user