diff --git a/front/package-lock.json b/front/package-lock.json index e3e3155..a0bcd0d 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -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", diff --git a/front/public/images/svg/arrow-left.svg b/front/public/images/svg/arrow-left.svg new file mode 100644 index 0000000..9577612 --- /dev/null +++ b/front/public/images/svg/arrow-left.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/front/public/images/svg/arrow-right.svg b/front/public/images/svg/arrow-right.svg new file mode 100644 index 0000000..ab416d8 --- /dev/null +++ b/front/public/images/svg/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/front/public/images/svg/close.svg b/front/public/images/svg/close.svg new file mode 100644 index 0000000..4a90caf --- /dev/null +++ b/front/public/images/svg/close.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/front/public/images/svg/menu.svg b/front/public/images/svg/menu.svg new file mode 100644 index 0000000..f60845b --- /dev/null +++ b/front/public/images/svg/menu.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/front/public/images/svg/plus.svg b/front/public/images/svg/plus.svg deleted file mode 100644 index 9940452..0000000 --- a/front/public/images/svg/plus.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/front/public/images/svg/upload.svg b/front/public/images/svg/upload.svg new file mode 100644 index 0000000..c712776 --- /dev/null +++ b/front/public/images/svg/upload.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/front/src/api/api.ts b/front/src/api/api.ts new file mode 100644 index 0000000..b2eb307 --- /dev/null +++ b/front/src/api/api.ts @@ -0,0 +1,51 @@ +import { BASE_URL } from './constants'; +import { ApiResponse } from './types'; +import { unpack } from './utils'; + +const send = async ( + url: string, + init: RequestInit, +): Promise> => { + 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 (url: string) => { + return send(url, { method: 'GET' }); + }, + post: async (url: string, body: unknown) => { + return send(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + put: async (url: string, body: unknown) => { + return send(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + delete: async (url: string) => { + return send(url, { method: 'DELETE' }); + }, +}; diff --git a/front/src/api/constants.ts b/front/src/api/constants.ts new file mode 100644 index 0000000..ee3d5e9 --- /dev/null +++ b/front/src/api/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = 'http://localhost:8000'; diff --git a/front/src/api/floris/constants.ts b/front/src/api/floris/constants.ts deleted file mode 100644 index fd812c1..0000000 --- a/front/src/api/floris/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// export const BASE_URL = 'http://localhost:8000/api'; -export const BASE_URL = 'http://192.168.1.110:8000/api'; diff --git a/front/src/api/floris/index.ts b/front/src/api/floris/index.ts deleted file mode 100644 index e34a0bf..0000000 --- a/front/src/api/floris/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { downloadImage, getWindmillData } from './service'; diff --git a/front/src/api/floris/service.ts b/front/src/api/floris/service.ts deleted file mode 100644 index 6e18fa6..0000000 --- a/front/src/api/floris/service.ts +++ /dev/null @@ -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) => { - 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; -}; diff --git a/front/src/api/floris/types.ts b/front/src/api/floris/types.ts deleted file mode 100644 index ff20ef1..0000000 --- a/front/src/api/floris/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type GetWindmillDataRes = { - file_name: string; - data: number[]; -}; diff --git a/front/src/api/floris/utils.ts b/front/src/api/floris/utils.ts deleted file mode 100644 index 7e7650c..0000000 --- a/front/src/api/floris/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { WindmillFormStore } from '@components/ux/windmill-form'; - -export const getWindmillDataParams = (store: Partial) => { - 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}`; -}; diff --git a/front/src/api/index.tsx b/front/src/api/index.tsx deleted file mode 100644 index 4b6c453..0000000 --- a/front/src/api/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './floris'; diff --git a/front/src/api/types.ts b/front/src/api/types.ts new file mode 100644 index 0000000..c2ff0b3 --- /dev/null +++ b/front/src/api/types.ts @@ -0,0 +1,9 @@ +export type ApiError = { + status: number; + message: string; +}; + +export type ApiResponse = { + data: T | null; + error: ApiError | null; +}; diff --git a/front/src/api/utils.ts b/front/src/api/utils.ts new file mode 100644 index 0000000..bad92dd --- /dev/null +++ b/front/src/api/utils.ts @@ -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; +}; diff --git a/front/src/api/wind/constants.ts b/front/src/api/wind/constants.ts new file mode 100644 index 0000000..1c118a7 --- /dev/null +++ b/front/src/api/wind/constants.ts @@ -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', +}; diff --git a/front/src/api/wind/index.ts b/front/src/api/wind/index.ts new file mode 100644 index 0000000..45237b7 --- /dev/null +++ b/front/src/api/wind/index.ts @@ -0,0 +1,2 @@ +export * from './service'; +export * from './types'; diff --git a/front/src/api/wind/service.ts b/front/src/api/wind/service.ts new file mode 100644 index 0000000..ca2b2ea --- /dev/null +++ b/front/src/api/wind/service.ts @@ -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(WIND_ENDPOINTS.turbines); +}; + +export const getTurbineType = (id: string) => { + const url = `${WIND_ENDPOINTS.turbineType}/${id}`; + return api.get(url); +}; + +export const createTurbineTypes = ( + formValues: Partial, +) => { + return api.post( + WIND_ENDPOINTS.turbineType, + packTurbineTypes(formValues), + ); +}; + +export const editTurbineTypes = ( + formValues: Partial, + id: string, +) => { + const url = `${WIND_ENDPOINTS.turbineType}/${id}`; + return api.put(url, packTurbineTypes(formValues)); +}; + +export const deleteTurbineType = (id: number) => { + const url = `${WIND_ENDPOINTS.turbineType}/${id}`; + return api.delete(url); +}; + +export const getParks = () => { + return api.get(WIND_ENDPOINTS.parks); +}; + +export const getPark = (id: string) => { + const url = `${WIND_ENDPOINTS.park}/${id}`; + return api.get(url); +}; + +export const getParkTurbines = (id: string) => { + const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`; + return api.get(url); +}; + +export const getParkWithTurbines = async ( + id: string, +): Promise> => { + const parkURL = `${WIND_ENDPOINTS.park}/${id}`; + const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`; + const parkPesponse = await api.get(parkURL); + const turbinesResponse = await api.get(turbinesURL); + return { + data: { ...parkPesponse.data, turbines: turbinesResponse.data }, + error: parkPesponse.error || turbinesResponse.error || null, + }; +}; + +export const createPark = async (formValues: Partial) => { + const parkPesponse = await api.post( + 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, + id: string, +) => { + const parkPesponse = await api.put( + `${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); +}; diff --git a/front/src/api/wind/types.ts b/front/src/api/wind/types.ts new file mode 100644 index 0000000..1921287 --- /dev/null +++ b/front/src/api/wind/types.ts @@ -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[]; +}; diff --git a/front/src/api/wind/utils.ts b/front/src/api/wind/utils.ts new file mode 100644 index 0000000..5e8f58f --- /dev/null +++ b/front/src/api/wind/utils.ts @@ -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) => { + return { + Name: values.name ?? '', + Height: parseInt(values.height || '0'), + BladeLength: parseInt(values.bladeLength || '0'), + }; +}; + +export const packPark = (values: Partial) => { + 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 ?? '', + }; +}; diff --git a/front/src/components/_func.scss b/front/src/components/_func.scss new file mode 100644 index 0000000..e1215e9 --- /dev/null +++ b/front/src/components/_func.scss @@ -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); +} diff --git a/front/src/components/_mixins.scss b/front/src/components/_mixins.scss index e69de29..58424a5 100644 --- a/front/src/components/_mixins.scss +++ b/front/src/components/_mixins.scss @@ -0,0 +1,5 @@ +@mixin on-mobile { + @media (width <= 800px) { + @content; + } +} diff --git a/front/src/components/app/_theme.scss b/front/src/components/app/_theme.scss index 4c375d7..43450ac 100644 --- a/front/src/components/app/_theme.scss +++ b/front/src/components/app/_theme.scss @@ -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; } diff --git a/front/src/components/app/component.tsx b/front/src/components/app/component.tsx new file mode 100644 index 0000000..ca8aa11 --- /dev/null +++ b/front/src/components/app/component.tsx @@ -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 ( + + + }> + } + /> + } /> + } /> + } /> + + } + /> + + + ); +} diff --git a/front/src/components/app/index.ts b/front/src/components/app/index.ts new file mode 100644 index 0000000..8e12b7e --- /dev/null +++ b/front/src/components/app/index.ts @@ -0,0 +1 @@ +export { App } from './component'; diff --git a/front/src/components/app/index.tsx b/front/src/components/app/index.tsx deleted file mode 100644 index fec961b..0000000 --- a/front/src/components/app/index.tsx +++ /dev/null @@ -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 ( - - - }> - } /> - - - - ); -} - -export default App; diff --git a/front/src/components/app/styles.scss b/front/src/components/app/styles.scss index b86f605..21c18b4 100644 --- a/front/src/components/app/styles.scss +++ b/front/src/components/app/styles.scss @@ -23,7 +23,7 @@ html[data-theme='default'] { } html { - --td-100: 0.2s; + --td-100: 0.1s; } body { diff --git a/front/src/components/layouts/index.ts b/front/src/components/layouts/index.ts new file mode 100644 index 0000000..f8c97de --- /dev/null +++ b/front/src/components/layouts/index.ts @@ -0,0 +1 @@ +export { MainLayout } from './main-layout'; diff --git a/front/src/components/layouts/index.tsx b/front/src/components/layouts/index.tsx deleted file mode 100644 index 6da568f..0000000 --- a/front/src/components/layouts/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import MainLayout from './main-layout'; - -export { MainLayout }; diff --git a/front/src/components/layouts/main-layout/component.tsx b/front/src/components/layouts/main-layout/component.tsx new file mode 100644 index 0000000..8949604 --- /dev/null +++ b/front/src/components/layouts/main-layout/component.tsx @@ -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 ( +
+ {deviceType === 'mobile' ? ( +
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/front/src/components/layouts/main-layout/index.tsx b/front/src/components/layouts/main-layout/index.tsx index e6f2193..bb82484 100644 --- a/front/src/components/layouts/main-layout/index.tsx +++ b/front/src/components/layouts/main-layout/index.tsx @@ -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 ( -
-
-
- -
-
- ); -} - -export default MainLayout; +export * from './component'; diff --git a/front/src/components/layouts/main-layout/styles.module.scss b/front/src/components/layouts/main-layout/styles.module.scss index 64e65a6..d5e9068 100644 --- a/front/src/components/layouts/main-layout/styles.module.scss +++ b/front/src/components/layouts/main-layout/styles.module.scss @@ -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); + } } diff --git a/front/src/components/pages/home-page/component.tsx b/front/src/components/pages/home-page/component.tsx deleted file mode 100644 index d1911b7..0000000 --- a/front/src/components/pages/home-page/component.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(null); - - const handleFormSuccess = (response: WindmillFormResponse) => { - setResult(response); - setError(null); - }; - - const handleFormFail = (message: string) => { - setError(message); - setResult(null); - }; - - return ( -
-
- -
-
- Result - {result && ( - <> -
{result.power.join(' ')}
-
- {result.image && Image} -
- - )} - {error && {error}} -
-
- ); -} diff --git a/front/src/components/pages/home-page/index.ts b/front/src/components/pages/home-page/index.ts deleted file mode 100644 index e2c7c55..0000000 --- a/front/src/components/pages/home-page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { HomePage } from './component'; diff --git a/front/src/components/pages/home-page/styles.module.scss b/front/src/components/pages/home-page/styles.module.scss deleted file mode 100644 index 16eb3b3..0000000 --- a/front/src/components/pages/home-page/styles.module.scss +++ /dev/null @@ -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; - } -} diff --git a/front/src/components/pages/index.ts b/front/src/components/pages/index.ts new file mode 100644 index 0000000..fbb3e4d --- /dev/null +++ b/front/src/components/pages/index.ts @@ -0,0 +1,4 @@ +export * from './park-page'; +export * from './parks-page'; +export * from './turbine-type-page'; +export * from './turbine-types-page'; diff --git a/front/src/components/pages/index.tsx b/front/src/components/pages/index.tsx deleted file mode 100644 index 1ae4201..0000000 --- a/front/src/components/pages/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { HomePage } from './home-page'; diff --git a/front/src/components/pages/park-page/component.tsx b/front/src/components/pages/park-page/component.tsx new file mode 100644 index 0000000..0cbcb8f --- /dev/null +++ b/front/src/components/pages/park-page/component.tsx @@ -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(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const params = useParams(); + const navigate = useNavigate(); + const route = useRoute(); + + const { register, control, getValues, reset } = useForm({}); + + 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 ( +
+ {route.title} +
+
+ {heading} +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+
+ + +
+ + ( + + )} + /> + setError(null)} + > + + +
+ ); +} diff --git a/front/src/components/ux/theme-select/index.tsx b/front/src/components/pages/park-page/index.ts similarity index 100% rename from front/src/components/ux/theme-select/index.tsx rename to front/src/components/pages/park-page/index.ts diff --git a/front/src/components/ux/windmill-form/styles.module.scss b/front/src/components/pages/park-page/styles.module.scss similarity index 53% rename from front/src/components/ux/windmill-form/styles.module.scss rename to front/src/components/pages/park-page/styles.module.scss index bb44db2..b204d88 100644 --- a/front/src/components/ux/windmill-form/styles.module.scss +++ b/front/src/components/pages/park-page/styles.module.scss @@ -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; } diff --git a/front/src/components/pages/park-page/types.ts b/front/src/components/pages/park-page/types.ts new file mode 100644 index 0000000..b366894 --- /dev/null +++ b/front/src/components/pages/park-page/types.ts @@ -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[]; +}; diff --git a/front/src/components/pages/park-page/utils.ts b/front/src/components/pages/park-page/utils.ts new file mode 100644 index 0000000..41d1260 --- /dev/null +++ b/front/src/components/pages/park-page/utils.ts @@ -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, + })), + }; +}; diff --git a/front/src/components/pages/parks-page/component.tsx b/front/src/components/pages/parks-page/component.tsx new file mode 100644 index 0000000..a006c1d --- /dev/null +++ b/front/src/components/pages/parks-page/component.tsx @@ -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([]); + const [selected, setSelected] = useState(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 ( +
+ {route.title} +
+ + + + {selected && ( + + + + )} + {selected && ( + + )} +
+
+ String(id)} + selectedItems={selected ? [selected] : []} + onItemsSelect={handleParkSelect} + multiselect={false} + /> +
+
+ ); +} diff --git a/front/src/components/pages/parks-page/constants.ts b/front/src/components/pages/parks-page/constants.ts new file mode 100644 index 0000000..11ed83b --- /dev/null +++ b/front/src/components/pages/parks-page/constants.ts @@ -0,0 +1,8 @@ +import { DataGridColumnConfig } from '@components/ui/data-grid/types'; +import { Park } from 'src/api/wind'; + +export const columns: DataGridColumnConfig[] = [ + { name: 'Name', getText: (t) => t.name, width: 2 }, + { name: 'Center Latitude', getText: (t) => String(t.centerLatitude) }, + { name: 'Center Longitude', getText: (t) => String(t.centerLongitude) }, +]; diff --git a/front/src/components/ux/windmill-table/index.tsx b/front/src/components/pages/parks-page/index.ts similarity index 100% rename from front/src/components/ux/windmill-table/index.tsx rename to front/src/components/pages/parks-page/index.ts diff --git a/front/src/components/pages/parks-page/styles.module.scss b/front/src/components/pages/parks-page/styles.module.scss new file mode 100644 index 0000000..ced7c80 --- /dev/null +++ b/front/src/components/pages/parks-page/styles.module.scss @@ -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; +} diff --git a/front/src/components/pages/turbine-type-page/component.tsx b/front/src/components/pages/turbine-type-page/component.tsx new file mode 100644 index 0000000..ed5e3d2 --- /dev/null +++ b/front/src/components/pages/turbine-type-page/component.tsx @@ -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(null); + const [pending, setPending] = useState(false); + const params = useParams(); + const navigate = useNavigate(); + const route = useRoute(); + + const { register, control, getValues, reset } = + useForm({}); + + 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 ( +
+ {route.title} +
+
+ {heading} +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+
+ + +
+ +
+ ); +} diff --git a/front/src/components/pages/turbine-type-page/index.ts b/front/src/components/pages/turbine-type-page/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/pages/turbine-type-page/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/pages/turbine-type-page/styles.module.scss b/front/src/components/pages/turbine-type-page/styles.module.scss new file mode 100644 index 0000000..b204d88 --- /dev/null +++ b/front/src/components/pages/turbine-type-page/styles.module.scss @@ -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; +} diff --git a/front/src/components/pages/turbine-type-page/types.ts b/front/src/components/pages/turbine-type-page/types.ts new file mode 100644 index 0000000..57aa074 --- /dev/null +++ b/front/src/components/pages/turbine-type-page/types.ts @@ -0,0 +1,5 @@ +export type TurbineTypeFormValues = { + name: string; + height: string; + bladeLength: string; +}; diff --git a/front/src/components/pages/turbine-type-page/utils.ts b/front/src/components/pages/turbine-type-page/utils.ts new file mode 100644 index 0000000..48436a5 --- /dev/null +++ b/front/src/components/pages/turbine-type-page/utils.ts @@ -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), + }; +}; diff --git a/front/src/components/pages/turbine-types-page/component.tsx b/front/src/components/pages/turbine-types-page/component.tsx new file mode 100644 index 0000000..628b19e --- /dev/null +++ b/front/src/components/pages/turbine-types-page/component.tsx @@ -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([]); + const [selected, setSelected] = useState(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 ( +
+ {route.title} +
+ + + + {selected && ( + + + + )} + {selected && ( + + )} +
+
+ String(id)} + selectedItems={selected ? [selected] : []} + onItemsSelect={handleTurbineTypeSelect} + multiselect={false} + /> +
+
+ ); +} diff --git a/front/src/components/pages/turbine-types-page/constants.ts b/front/src/components/pages/turbine-types-page/constants.ts new file mode 100644 index 0000000..c81bdfe --- /dev/null +++ b/front/src/components/pages/turbine-types-page/constants.ts @@ -0,0 +1,8 @@ +import { DataGridColumnConfig } from '@components/ui/data-grid/types'; +import { TurbineType } from 'src/api/wind'; + +export const columns: DataGridColumnConfig[] = [ + { name: 'Name', getText: (t) => t.name, width: 2 }, + { name: 'Height', getText: (t) => String(t.height) }, + { name: 'Blade length', getText: (t) => String(t.bladeLength) }, +]; diff --git a/front/src/components/pages/turbine-types-page/index.ts b/front/src/components/pages/turbine-types-page/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/pages/turbine-types-page/styles.module.scss b/front/src/components/pages/turbine-types-page/styles.module.scss new file mode 100644 index 0000000..ced7c80 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/styles.module.scss @@ -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; +} diff --git a/front/src/components/ui/animation/fade/component.tsx b/front/src/components/ui/animation/fade/component.tsx index 708b0d1..f747bf9 100644 --- a/front/src/components/ui/animation/fade/component.tsx +++ b/front/src/components/ui/animation/fade/component.tsx @@ -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, ref: ForwardedRef, ) { - const [visibleInner, setVisibleInner] = useState(visible); + const [visibleInternal, setVisibleInternal] = useState(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; } diff --git a/front/src/components/ui/animation/fade/styles.module.scss b/front/src/components/ui/animation/fade/styles.module.scss index 6a940c6..b26b897 100644 --- a/front/src/components/ui/animation/fade/styles.module.scss +++ b/front/src/components/ui/animation/fade/styles.module.scss @@ -1,5 +1,5 @@ .fade { - animation: fadein var(--animation-duration); + animation: fadein var(--animation-duration) ease-in-out; } .invisible { diff --git a/front/src/components/ui/animation/fade/types.ts b/front/src/components/ui/animation/fade/types.ts index 10216fb..12a3f3d 100644 --- a/front/src/components/ui/animation/fade/types.ts +++ b/front/src/components/ui/animation/fade/types.ts @@ -1,4 +1,6 @@ +import { ComponentProps } from 'react'; + export type FadeProps = { visible: boolean; duration?: number; -} & React.ComponentProps<'div'>; +} & ComponentProps<'div'>; diff --git a/front/src/components/ui/animation/ripple/component.tsx b/front/src/components/ui/animation/ripple/component.tsx index 7abd6f4..0faee3a 100644 --- a/front/src/components/ui/animation/ripple/component.tsx +++ b/front/src/components/ui/animation/ripple/component.tsx @@ -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, -) { +export function Ripple() { const rippleRef = useRef(null); - const [waves, setWaves] = useState([]); - 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 = ( - - ); - 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 (
- {waves} -
+ ref={rippleRef} + onPointerDown={handlePointerDown} + /> ); } - -export const Ripple = forwardRef(RippleInner); diff --git a/front/src/components/ui/animation/ripple/parts/ripple-wave/component.tsx b/front/src/components/ui/animation/ripple/parts/ripple-wave/component.tsx deleted file mode 100644 index 1227520..0000000 --- a/front/src/components/ui/animation/ripple/parts/ripple-wave/component.tsx +++ /dev/null @@ -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 ( -
- ); -} diff --git a/front/src/components/ui/animation/ripple/parts/ripple-wave/index.ts b/front/src/components/ui/animation/ripple/parts/ripple-wave/index.ts deleted file mode 100644 index 74e0c72..0000000 --- a/front/src/components/ui/animation/ripple/parts/ripple-wave/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RippleWave } from './component'; diff --git a/front/src/components/ui/animation/ripple/parts/ripple-wave/style.module.scss b/front/src/components/ui/animation/ripple/parts/ripple-wave/style.module.scss deleted file mode 100644 index a9319f5..0000000 --- a/front/src/components/ui/animation/ripple/parts/ripple-wave/style.module.scss +++ /dev/null @@ -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; - } -} diff --git a/front/src/components/ui/animation/ripple/parts/ripple-wave/types.ts b/front/src/components/ui/animation/ripple/parts/ripple-wave/types.ts deleted file mode 100644 index 3588e4d..0000000 --- a/front/src/components/ui/animation/ripple/parts/ripple-wave/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CSSProperties } from 'react'; - -export type RippleWaveProps = { - style: CSSProperties; - onDone: () => void; -}; diff --git a/front/src/components/ui/animation/ripple/styles.module.scss b/front/src/components/ui/animation/ripple/styles.module.scss index 98abe03..3495443 100644 --- a/front/src/components/ui/animation/ripple/styles.module.scss +++ b/front/src/components/ui/animation/ripple/styles.module.scss @@ -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; + } +} diff --git a/front/src/components/ui/animation/ripple/types.ts b/front/src/components/ui/animation/ripple/types.ts deleted file mode 100644 index af8def9..0000000 --- a/front/src/components/ui/animation/ripple/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type RippleProps = {} & React.ComponentProps<'div'>; diff --git a/front/src/components/ui/animation/ripple/utils.ts b/front/src/components/ui/animation/ripple/utils.ts index 55e9188..5dfa7a6 100644 --- a/front/src/components/ui/animation/ripple/utils.ts +++ b/front/src/components/ui/animation/ripple/utils.ts @@ -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) }; }; diff --git a/front/src/components/ui/autocomplete/component.tsx b/front/src/components/ui/autocomplete/component.tsx new file mode 100644 index 0000000..216c12d --- /dev/null +++ b/front/src/components/ui/autocomplete/component.tsx @@ -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( + { + options, + value, + getOptionKey, + getOptionLabel, + onChange, + scale = 'm', + label = {}, + name, + id, + }: Omit, 'ref'>, + ref: ForwardedRef, +) { + const autocompleteRef = useRef(null); + const menuRef = useRef(null); + const inputWrapperRef = useRef(null); + const [menuVisible, setMenuVisible] = useState(false); + const [text, setText] = useState(''); + + 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) => { + 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 ( +
+ + +
+ } + /> + + +
+ } + /> + + ); +} + +export const Autocomplete = forwardRef(AutocompleteInner) as ( + props: AutocompleteProps, +) => ReturnType; diff --git a/front/src/components/ui/autocomplete/index.ts b/front/src/components/ui/autocomplete/index.ts new file mode 100644 index 0000000..5b99bce --- /dev/null +++ b/front/src/components/ui/autocomplete/index.ts @@ -0,0 +1,3 @@ +export { Autocomplete } from './component'; +export { AutocompletePreview } from './preview'; +export { type AutocompleteProps } from './types'; diff --git a/front/src/components/ui/autocomplete/preview.tsx b/front/src/components/ui/autocomplete/preview.tsx new file mode 100644 index 0000000..192a8ef --- /dev/null +++ b/front/src/components/ui/autocomplete/preview.tsx @@ -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(); + const options = ['Orange', 'Banana', 'Apple', 'Avocado']; + + return ( + + o} + getOptionLabel={(o) => o} + label={{ text: 'Select your favorite fruit' }} + scale="s" + value={selectValue} + onChange={(o) => setSelectValue(o)} + name="fruit" + /> + o} + getOptionLabel={(o) => o} + label={{ text: 'Select your favorite fruit' }} + scale="m" + value={selectValue} + onChange={(o) => setSelectValue(o)} + name="fruit" + /> + o} + getOptionLabel={(o) => o} + label={{ text: 'Select your favorite fruit' }} + scale="l" + value={selectValue} + onChange={(o) => setSelectValue(o)} + name="fruit" + /> + + ); +} diff --git a/front/src/components/ui/autocomplete/styles.module.scss b/front/src/components/ui/autocomplete/styles.module.scss new file mode 100644 index 0000000..f0a648d --- /dev/null +++ b/front/src/components/ui/autocomplete/styles.module.scss @@ -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); + } +} diff --git a/front/src/components/ui/autocomplete/types.ts b/front/src/components/ui/autocomplete/types.ts new file mode 100644 index 0000000..f4bcb12 --- /dev/null +++ b/front/src/components/ui/autocomplete/types.ts @@ -0,0 +1,16 @@ +import { ComponentProps, Key } from 'react'; + +import { LabelProps } from '../label'; +import { Scale } from '../types'; + +export type AutocompleteProps = { + 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, 'onChange'>; diff --git a/front/src/components/ui/button/component.tsx b/front/src/components/ui/button/component.tsx index 5344602..eeb0af8 100644 --- a/front/src/components/ui/button/component.tsx +++ b/front/src/components/ui/button/component.tsx @@ -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, +) { + const buttonClassName = clsx( styles.button, styles[variant], styles[scale], @@ -25,11 +27,7 @@ export function Button({ className, ); return ( - + {pending && (
@@ -40,3 +38,5 @@ export function Button({ ); } + +export const Button = forwardRef(ButtonInner); diff --git a/front/src/components/ui/button/styles.module.scss b/front/src/components/ui/button/styles.module.scss index 4069c35..6830e1a 100644 --- a/front/src/components/ui/button/styles.module.scss +++ b/front/src/components/ui/button/styles.module.scss @@ -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,42 +34,42 @@ 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); + } } } .secondary { 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); } diff --git a/front/src/components/ui/calendar/component.tsx b/front/src/components/ui/calendar/component.tsx index 33e5451..a44ef64 100644 --- a/front/src/components/ui/calendar/component.tsx +++ b/front/src/components/ui/calendar/component.tsx @@ -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( diff --git a/front/src/components/ui/calendar/parts/calendar-days/component.tsx b/front/src/components/ui/calendar/components/calendar-days/component.tsx similarity index 98% rename from front/src/components/ui/calendar/parts/calendar-days/component.tsx rename to front/src/components/ui/calendar/components/calendar-days/component.tsx index da2bc54..7c3b559 100644 --- a/front/src/components/ui/calendar/parts/calendar-days/component.tsx +++ b/front/src/components/ui/calendar/components/calendar-days/component.tsx @@ -33,7 +33,7 @@ export function CalendarDays({ }, [date, min, max]); const handleChange = (newValue: string) => { - onChange?.(newValue); + onChange(newValue); }; return ( diff --git a/front/src/components/ui/calendar/parts/calendar-days/constants.ts b/front/src/components/ui/calendar/components/calendar-days/constants.ts similarity index 100% rename from front/src/components/ui/calendar/parts/calendar-days/constants.ts rename to front/src/components/ui/calendar/components/calendar-days/constants.ts diff --git a/front/src/components/ui/calendar/parts/calendar-days/index.ts b/front/src/components/ui/calendar/components/calendar-days/index.ts similarity index 100% rename from front/src/components/ui/calendar/parts/calendar-days/index.ts rename to front/src/components/ui/calendar/components/calendar-days/index.ts diff --git a/front/src/components/ui/calendar/parts/calendar-days/styles.module.scss b/front/src/components/ui/calendar/components/calendar-days/styles.module.scss similarity index 87% rename from front/src/components/ui/calendar/parts/calendar-days/styles.module.scss rename to front/src/components/ui/calendar/components/calendar-days/styles.module.scss index 46dd70c..652a3b6 100644 --- a/front/src/components/ui/calendar/parts/calendar-days/styles.module.scss +++ b/front/src/components/ui/calendar/components/calendar-days/styles.module.scss @@ -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); } } diff --git a/front/src/components/ui/calendar/parts/calendar-days/types.ts b/front/src/components/ui/calendar/components/calendar-days/types.ts similarity index 92% rename from front/src/components/ui/calendar/parts/calendar-days/types.ts rename to front/src/components/ui/calendar/components/calendar-days/types.ts index ccd3598..7f6d27f 100644 --- a/front/src/components/ui/calendar/parts/calendar-days/types.ts +++ b/front/src/components/ui/calendar/components/calendar-days/types.ts @@ -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; diff --git a/front/src/components/ui/calendar/parts/calendar-days/utils.ts b/front/src/components/ui/calendar/components/calendar-days/utils.ts similarity index 77% rename from front/src/components/ui/calendar/parts/calendar-days/utils.ts rename to front/src/components/ui/calendar/components/calendar-days/utils.ts index 7acda21..1eb333e 100644 --- a/front/src/components/ui/calendar/parts/calendar-days/utils.ts +++ b/front/src/components/ui/calendar/components/calendar-days/utils.ts @@ -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() && diff --git a/front/src/components/ui/calendar/parts/index.ts b/front/src/components/ui/calendar/components/index.ts similarity index 100% rename from front/src/components/ui/calendar/parts/index.ts rename to front/src/components/ui/calendar/components/index.ts diff --git a/front/src/components/ui/calendar/types.ts b/front/src/components/ui/calendar/types.ts index 107f219..13481a1 100644 --- a/front/src/components/ui/calendar/types.ts +++ b/front/src/components/ui/calendar/types.ts @@ -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, 'onChange'>; +} & Omit, 'onChange'>; diff --git a/front/src/components/ui/checkbox-group/styles.module.scss b/front/src/components/ui/checkbox-group/styles.module.scss index b1533e6..2393402 100644 --- a/front/src/components/ui/checkbox-group/styles.module.scss +++ b/front/src/components/ui/checkbox-group/styles.module.scss @@ -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); } } diff --git a/front/src/components/ui/checkbox/component.tsx b/front/src/components/ui/checkbox/component.tsx index fc287ed..074113c 100644 --- a/front/src/components/ui/checkbox/component.tsx +++ b/front/src/components/ui/checkbox/component.tsx @@ -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'; diff --git a/front/src/components/ui/checkbox/styles.module.scss b/front/src/components/ui/checkbox/styles.module.scss index 560f3c8..7a348d7 100644 --- a/front/src/components/ui/checkbox/styles.module.scss +++ b/front/src/components/ui/checkbox/styles.module.scss @@ -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); } } diff --git a/front/src/components/ui/comet/styles.module.scss b/front/src/components/ui/comet/styles.module.scss index 1df98da..13fef98 100644 --- a/front/src/components/ui/comet/styles.module.scss +++ b/front/src/components/ui/comet/styles.module.scss @@ -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)); diff --git a/front/src/components/ui/comet/types.ts b/front/src/components/ui/comet/types.ts index 2508855..be329ec 100644 --- a/front/src/components/ui/comet/types.ts +++ b/front/src/components/ui/comet/types.ts @@ -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'>; diff --git a/front/src/components/ui/data-grid/component.tsx b/front/src/components/ui/data-grid/component.tsx new file mode 100644 index 0000000..1927b22 --- /dev/null +++ b/front/src/components/ui/data-grid/component.tsx @@ -0,0 +1,77 @@ +import React, { useMemo, useState } from 'react'; + +import { DataGridHeader, DataGridRow } from './components'; +import { DataGridProps } from './types'; + +export function DataGrid({ + items, + columns, + getItemKey, + selectedItems, + onItemsSelect, + multiselect, + className, + ...props +}: DataGridProps) { + const [allItemsSelected, setAllItemsSelected] = useState(false); + + const columnsTemplate = useMemo(() => { + const main = columns.map((c) => `${c.width ?? 1}fr`).join(' '); + return `auto ${main}`; + }, []); + + const selectedItemsMap = useMemo(() => { + const map: Record = {}; + 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 ( +
+ + {items.map((item) => { + const key = String(getItemKey(item)); + return ( + handleItemSelect(key, item)} + key={getItemKey(item)} + columnsTemplate={columnsTemplate} + /> + ); + })} +
+ ); +} diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx b/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx new file mode 100644 index 0000000..5baf054 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridHeader/component.tsx @@ -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({ + columns, + allItemsSelected, + onSelectAllItems, + columnsTemplate, +}: DataGridHeaderProps) { + const [sort, setSort] = useState({ 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 ( +
+ + {columns.map((column) => { + const isActive = sort.column === column.name; + const cellClassName = clsx(styles.cell, { + [styles.activeCell]: isActive, + [styles.desc]: isActive && sort.order === 'desc', + }); + return ( + handleSortButtonClick(column.name)} + > + + {column.name} + + + + + ); + })} +
+ ); +} diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/index.ts b/front/src/components/ui/data-grid/components/DataGridHeader/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridHeader/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss new file mode 100644 index 0000000..9e87119 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss @@ -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; + } +} diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/types.ts b/front/src/components/ui/data-grid/components/DataGridHeader/types.ts new file mode 100644 index 0000000..013d3fd --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridHeader/types.ts @@ -0,0 +1,8 @@ +import { DataGridColumnConfig } from '../../types'; + +export type DataGridHeaderProps = { + columns: DataGridColumnConfig[]; + allItemsSelected: boolean; + onSelectAllItems: () => void; + columnsTemplate: string; +}; diff --git a/front/src/components/ui/data-grid/components/DataGridRow/component.tsx b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx new file mode 100644 index 0000000..ec35b86 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx @@ -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({ + object, + columns, + selected, + onSelect, + columnsTemplate, +}: DataGridRowProps) { + return ( +
+ + {columns.map((column) => ( +
+ {column.getText(object)} +
+ ))} +
+ ); +} diff --git a/front/src/components/ui/data-grid/components/DataGridRow/index.ts b/front/src/components/ui/data-grid/components/DataGridRow/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridRow/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss new file mode 100644 index 0000000..3513e15 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss @@ -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; +} diff --git a/front/src/components/ui/data-grid/components/DataGridRow/types.ts b/front/src/components/ui/data-grid/components/DataGridRow/types.ts new file mode 100644 index 0000000..54fc089 --- /dev/null +++ b/front/src/components/ui/data-grid/components/DataGridRow/types.ts @@ -0,0 +1,9 @@ +import { DataGridColumnConfig } from '../../types'; + +export type DataGridRowProps = { + object: T; + columns: DataGridColumnConfig[]; + selected: boolean; + onSelect: () => void; + columnsTemplate: string; +}; diff --git a/front/src/components/ui/data-grid/components/index.ts b/front/src/components/ui/data-grid/components/index.ts new file mode 100644 index 0000000..f703f00 --- /dev/null +++ b/front/src/components/ui/data-grid/components/index.ts @@ -0,0 +1,2 @@ +export * from './DataGridHeader'; +export * from './DataGridRow'; diff --git a/front/src/components/ui/data-grid/index.ts b/front/src/components/ui/data-grid/index.ts new file mode 100644 index 0000000..fb2697a --- /dev/null +++ b/front/src/components/ui/data-grid/index.ts @@ -0,0 +1,2 @@ +export * from './component'; +export * from './preview'; diff --git a/front/src/components/ui/data-grid/preview.tsx b/front/src/components/ui/data-grid/preview.tsx new file mode 100644 index 0000000..710c434 --- /dev/null +++ b/front/src/components/ui/data-grid/preview.tsx @@ -0,0 +1,46 @@ +import { PreviewArticle } from '@components/ui/preview'; +import React, { useState } from 'react'; + +import { DataGrid } from './component'; +import { DataGridColumnConfig } from './types'; + +type Cat = { + name: string; + breed: string; + age: string; + color: string; +}; + +export function DataGridPreview() { + const [selectedItems, setSelectedItems] = useState([]); + + const items: Cat[] = [ + { name: 'Luna', breed: 'British Shorthair', color: 'Gray', age: '2' }, + { name: 'Simba', breed: 'Siamese', color: 'Cream', age: '1' }, + { name: 'Bella', breed: 'Maine Coon', color: 'Brown Tabby', age: '3' }, + { name: 'Oliver', breed: 'Persian', color: 'White', age: '4' }, + { name: 'Milo', breed: 'Sphynx', color: 'Pink', age: '2' }, + ]; + + const columns: DataGridColumnConfig[] = [ + { name: 'Name', getText: (cat) => cat.name }, + { name: 'Breed', getText: (cat) => cat.breed, width: 2 }, + { name: 'Age', getText: (cat) => cat.age }, + { name: 'Color', getText: (cat) => cat.color }, + ]; + + return ( + +
+ name} + selectedItems={selectedItems} + onItemsSelect={setSelectedItems} + /> +
+
+ ); +} diff --git a/front/src/components/ui/data-grid/types.ts b/front/src/components/ui/data-grid/types.ts new file mode 100644 index 0000000..736918e --- /dev/null +++ b/front/src/components/ui/data-grid/types.ts @@ -0,0 +1,22 @@ +import { ComponentPropsWithoutRef, Key } from 'react'; + +export type DataGridColumnConfig = { + name: string; + getText: (object: T) => string; + sortable?: boolean; + width?: number; +}; + +export type DataGridSort = { + order: 'asc' | 'desc'; + column: string; +}; + +export type DataGridProps = { + items: T[]; + columns: DataGridColumnConfig[]; + getItemKey: (item: T) => Key; + selectedItems?: T[]; + onItemsSelect?: (selectedItems: T[]) => void; + multiselect?: boolean; +} & ComponentPropsWithoutRef<'div'>; diff --git a/front/src/components/ui/date-input/component.tsx b/front/src/components/ui/date-input/component.tsx index 5eaf3c6..1a80cd3 100644 --- a/front/src/components/ui/date-input/component.tsx +++ b/front/src/components/ui/date-input/component.tsx @@ -1,5 +1,4 @@ import CalendarIcon from '@public/images/svg/calendar.svg'; -import { px } from '@utils/css'; import { useMissClick } from '@utils/miss-click'; import React, { useEffect, useMemo, useRef, useState } from 'react'; @@ -38,16 +37,24 @@ 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); }; + const handleCalendarButtonMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + const handleCalendarButtonMouseUp = (event: React.MouseEvent) => { + event.preventDefault(); + }; + const handleInputChange = (event: React.ChangeEvent) => { const newDirtyDate = inputToDirtyDate(event.target.value); if (newDirtyDate.length === 10) { @@ -58,42 +65,19 @@ export function DateInput({ (!minDate || date >= minDate) && (!maxDate || date <= maxDate) ) { - onChange?.(newValue); + onChange(newValue); } else { - onChange?.(''); + onChange(''); } } setDirtyDate(newDirtyDate); }; const handleCalendarChange = (newValue: string) => { - onChange?.(newValue); + onChange(newValue); setCalendarVisible(false); }; - const calcPopoverStyles = (calendarRect: DOMRect) => { - if (calendarRect === null) { - return {}; - } - - const inputWrapperRect = inputWrapperRef.current.getBoundingClientRect(); - const { left, bottom, top } = inputWrapperRect; - - const rightSpace = window.innerWidth - left; - const rightOverflow = calendarRect.width - rightSpace; - const bottomSpace = window.innerHeight - bottom; - - const popoverLeft = rightOverflow <= 0 ? left : left - rightOverflow; - - const popoverTop = - bottomSpace >= calendarRect.height ? bottom : top - calendarRect.height; - - return { - left: px(popoverLeft), - top: px(popoverTop), - }; - }; - return (
- + {rightNode} @@ -113,7 +102,10 @@ export function DateInput({ /> void; + onChange: (value: string) => void; max?: string; min?: string; } & Omit; diff --git a/front/src/components/ui/dialog/component.tsx b/front/src/components/ui/dialog/component.tsx new file mode 100644 index 0000000..0fcfa10 --- /dev/null +++ b/front/src/components/ui/dialog/component.tsx @@ -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(null); + + useMissClick({ + callback: onClose, + enabled: open, + whitelist: [dialogRef, triggerElementRef], + }); + + const dialogClassName = clsx(styles.dialog, className); + + return ( + + +
+ {heading} + {message} + {children} +
+
+
+ ); +} diff --git a/front/src/components/ui/dialog/index.ts b/front/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..fb2697a --- /dev/null +++ b/front/src/components/ui/dialog/index.ts @@ -0,0 +1,2 @@ +export * from './component'; +export * from './preview'; diff --git a/front/src/components/ui/dialog/preview.tsx b/front/src/components/ui/dialog/preview.tsx new file mode 100644 index 0000000..e3a9d44 --- /dev/null +++ b/front/src/components/ui/dialog/preview.tsx @@ -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(null); + const [open, setOpen] = useState(false); + + return ( + + + setOpen(false)} + heading="Dialog" + message="Short dialog message" + triggerElementRef={openButtonRef} + > +
+ + +
+
+
+ ); +} diff --git a/front/src/components/ui/dialog/styles.module.scss b/front/src/components/ui/dialog/styles.module.scss new file mode 100644 index 0000000..71b4e34 --- /dev/null +++ b/front/src/components/ui/dialog/styles.module.scss @@ -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; +} diff --git a/front/src/components/ui/dialog/types.ts b/front/src/components/ui/dialog/types.ts new file mode 100644 index 0000000..9478b35 --- /dev/null +++ b/front/src/components/ui/dialog/types.ts @@ -0,0 +1,9 @@ +import { ComponentPropsWithoutRef, MutableRefObject } from 'react'; + +export type DialogProps = { + heading: string; + message: string; + open: boolean; + onClose: () => void; + triggerElementRef?: MutableRefObject; +} & ComponentPropsWithoutRef<'div'>; diff --git a/front/src/components/ui/file-uploader/component.tsx b/front/src/components/ui/file-uploader/component.tsx new file mode 100644 index 0000000..d797c96 --- /dev/null +++ b/front/src/components/ui/file-uploader/component.tsx @@ -0,0 +1,78 @@ +import UploadIcon from '@public/images/svg/upload.svg'; +import { getFileExtension } from '@utils/file'; +import clsx from 'clsx'; +import React, { useRef } from 'react'; + +import { Ripple } from '../animation'; +import { Label } from '../label'; +import { RawButton, RawInput } from '../raw'; +import { Span } from '../span'; +import styles from './style.module.scss'; +import { FileUploaderProps } from './types'; + +export function FileUploader({ + extensions, + onChange, + scale = 'm', + label = {}, + input = {}, + ...props +}: FileUploaderProps) { + const inputRef = useRef(null); + const uploaderClassName = clsx(styles.uploader, styles[scale]); + + const handleChange = (files: FileList) => { + if (!files || !onChange) { + return; + } + const array = Array.from(files); + const filtered = extensions + ? array.filter((file) => extensions.includes(getFileExtension(file))) + : array; + onChange(filtered); + }; + + const handleButtonClick = () => { + inputRef.current.click(); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + handleChange(event.target.files); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + handleChange(event.dataTransfer.files); + }; + + return ( + + ); +} diff --git a/front/src/components/ui/file-uploader/index.ts b/front/src/components/ui/file-uploader/index.ts new file mode 100644 index 0000000..1d94b2f --- /dev/null +++ b/front/src/components/ui/file-uploader/index.ts @@ -0,0 +1 @@ +export { FileUploader } from './component'; diff --git a/front/src/components/ui/file-uploader/preview.tsx b/front/src/components/ui/file-uploader/preview.tsx new file mode 100644 index 0000000..8108d07 --- /dev/null +++ b/front/src/components/ui/file-uploader/preview.tsx @@ -0,0 +1,14 @@ +import { PreviewArticle } from '@components/ui/preview'; +import React from 'react'; + +import { FileUploader } from './component'; + +export function FileUploaderPreview() { + return ( + + + + + + ); +} diff --git a/front/src/components/ui/file-uploader/style.module.scss b/front/src/components/ui/file-uploader/style.module.scss new file mode 100644 index 0000000..c90402e --- /dev/null +++ b/front/src/components/ui/file-uploader/style.module.scss @@ -0,0 +1,68 @@ +@use '@components/func.scss' as f; + +.uploader { + position: relative; + display: flex; + overflow: hidden; + flex-direction: column; + align-items: center; + border: 1px dashed var(--clr-border-200); + background-color: var(--clr-layer-300); + box-shadow: 0px 2px 2px var(--clr-shadow-100); + cursor: pointer; + transition: all var(--td-100) ease-in-out; + + &:not(.wrapperFocus):hover { + background-color: var(--clr-layer-300-hover); + } +} + +.input { + display: none; +} + +.icon { + fill: var(--clr-text-100); +} + +$padding: 10px 16px; +$border-radius: 8px; +$font-size: 12px; +$icon-size: 24px; +$gap: 5px; + +.s { + padding: $padding; + border-radius: $border-radius; + font-size: $font-size; + gap: $gap; + + .icon { + width: $icon-size; + height: $icon-size; + } +} + +.m { + padding: f.m($padding); + border-radius: f.m($border-radius); + font-size: f.m($font-size); + gap: f.m($gap); + + .icon { + width: f.m($icon-size); + height: f.m($icon-size); + } +} + +.l { + padding: f.l($padding); + border-radius: f.l($border-radius); + font-size: f.l($font-size); + gap: f.l($gap); + + .icon { + width: f.l($icon-size); + height: f.l($icon-size); + } +} diff --git a/front/src/components/ui/file-uploader/types.ts b/front/src/components/ui/file-uploader/types.ts new file mode 100644 index 0000000..564bbdc --- /dev/null +++ b/front/src/components/ui/file-uploader/types.ts @@ -0,0 +1,13 @@ +import { ComponentPropsWithoutRef } from 'react'; + +import { LabelProps } from '../label'; +import { RawInputProps } from '../raw'; +import { Scale } from '../types'; + +export type FileUploaderProps = { + extensions?: string[]; + onChange?: (value: File[]) => void; + scale?: Scale; + label?: LabelProps; + input?: Omit; +} & Omit, 'onChange'>; diff --git a/front/src/components/ui/heading/types.ts b/front/src/components/ui/heading/types.ts index 0b11346..d9e8965 100644 --- a/front/src/components/ui/heading/types.ts +++ b/front/src/components/ui/heading/types.ts @@ -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 = { tag: T; color?: TextColor; -} & React.ComponentProps; +} & ComponentProps; diff --git a/front/src/components/ui/icon-button/component.tsx b/front/src/components/ui/icon-button/component.tsx index 1e8fcbd..1f30bb5 100644 --- a/front/src/components/ui/icon-button/component.tsx +++ b/front/src/components/ui/icon-button/component.tsx @@ -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({ - scale = 'm', - className, - children, - ...props -}: IconButtonProps) { - const classes = clsx(styles.button, styles[scale], className); +function IconButtonInner( + { + variant = 'circle', + scale = 'm', + className, + children, + ...props + }: IconButtonProps, + ref: ForwardedRef, +) { + const buttonClassName = clsx( + styles.button, + styles[scale], + styles[variant], + className, + ); return ( - + {children} ); } + +export const IconButton = forwardRef(IconButtonInner); diff --git a/front/src/components/ui/icon-button/styles.module.scss b/front/src/components/ui/icon-button/styles.module.scss index f971457..d0c39c1 100644 --- a/front/src/components/ui/icon-button/styles.module.scss +++ b/front/src/components/ui/icon-button/styles.module.scss @@ -1,41 +1,77 @@ +@use '@components/func.scss' as f; + .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; +$rect-padding: 6px; +$circle-padding: 4px; +$border-radius: 8px; + .s { - width: 27px; - height: 27px; - padding: 4px; + width: $size; + height: $size; + + &.rect { + padding: $rect-padding; + border-radius: $border-radius; + } + + &.circle { + padding: $circle-padding; + } } .m { - width: 35px; - height: 35px; - padding: 6px; + width: f.m($size); + height: f.m($size); + + &.rect { + padding: f.m($rect-padding); + border-radius: f.m($border-radius); + } + + &.circle { + padding: f.m($circle-padding); + } } .l { - width: 43px; - height: 43px; - padding: 8px; + width: f.l($size); + height: f.l($size); + + &.rect { + padding: f.l($rect-padding); + border-radius: f.l($border-radius); + } + + &.circle { + padding: f.l($circle-padding); + } } diff --git a/front/src/components/ui/icon-button/types.ts b/front/src/components/ui/icon-button/types.ts index 82de98a..bfaee29 100644 --- a/front/src/components/ui/icon-button/types.ts +++ b/front/src/components/ui/icon-button/types.ts @@ -3,5 +3,6 @@ import { Scale } from '@components/ui/types'; import { RawButtonProps } from '../raw'; export type IconButtonProps = { + variant?: 'circle' | 'rect'; scale?: Scale; } & RawButtonProps; diff --git a/front/src/components/ui/image-file-manager/component.tsx b/front/src/components/ui/image-file-manager/component.tsx new file mode 100644 index 0000000..e652e7a --- /dev/null +++ b/front/src/components/ui/image-file-manager/component.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { FileUploader } from '../file-uploader'; +import { ImageViewer } from '../image-viewer'; +import styles from './styles.module.scss'; +import { ImageFileManagerProps } from './types'; + +export function ImageFileManager({ + value, + onChange, + scale = 'm', + label = {}, +}: ImageFileManagerProps) { + const managerClassName = clsx(styles.manager, styles[scale]); + + const handleFileUploaderChange = (files: File[]) => { + const file = files[0]; + if (!file) { + return; + } + onChange?.(file); + }; + + const handleClear = () => { + onChange?.(null); + }; + + return ( +
+ + +
+ ); +} diff --git a/front/src/components/ui/image-file-manager/index.ts b/front/src/components/ui/image-file-manager/index.ts new file mode 100644 index 0000000..0fff9fa --- /dev/null +++ b/front/src/components/ui/image-file-manager/index.ts @@ -0,0 +1 @@ +export { ImageFileManager } from './component'; diff --git a/front/src/components/ui/image-file-manager/preview.tsx b/front/src/components/ui/image-file-manager/preview.tsx new file mode 100644 index 0000000..4ecbe2f --- /dev/null +++ b/front/src/components/ui/image-file-manager/preview.tsx @@ -0,0 +1,31 @@ +import { PreviewArticle } from '@components/ui/preview'; +import React, { useState } from 'react'; + +import { ImageFileManager } from './component'; + +export function ImageFileManagerPreview() { + const [value, setValue] = useState(null); + + return ( + + + + + + ); +} diff --git a/front/src/components/ui/image-file-manager/styles.module.scss b/front/src/components/ui/image-file-manager/styles.module.scss new file mode 100644 index 0000000..80eff0a --- /dev/null +++ b/front/src/components/ui/image-file-manager/styles.module.scss @@ -0,0 +1,20 @@ +@use '@components/func.scss' as f; + +.manager { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +$gap: 12px; + +.s { + gap: $gap; +} + +.m { + gap: f.m($gap); +} + +.l { + gap: f.l($gap); +} diff --git a/front/src/components/ui/image-file-manager/types.ts b/front/src/components/ui/image-file-manager/types.ts new file mode 100644 index 0000000..9014ff8 --- /dev/null +++ b/front/src/components/ui/image-file-manager/types.ts @@ -0,0 +1,9 @@ +import { LabelProps } from '../label'; +import { Scale } from '../types'; + +export type ImageFileManagerProps = { + value?: File | null; + onChange?: (value: File | null) => void; + scale?: Scale; + label?: LabelProps; +} & Omit, 'onChange'>; diff --git a/front/src/components/ui/image-viewer/component.tsx b/front/src/components/ui/image-viewer/component.tsx new file mode 100644 index 0000000..f7860ec --- /dev/null +++ b/front/src/components/ui/image-viewer/component.tsx @@ -0,0 +1,32 @@ +import DeleteIcon from '@public/images/svg/delete.svg'; +import { formatFileSize } from '@utils/file'; +import clsx from 'clsx'; +import React from 'react'; + +import { IconButton } from '../icon-button'; +import { Span } from '../span'; +import styles from './styles.module.scss'; +import { ImageViewerProps } from './types'; + +export function ImageViewer({ file, scale = 'm', onClear }: ImageViewerProps) { + const viewerClassName = clsx(styles.viewer, styles[scale]); + return ( +
+ {file ? ( + <> + +
+ {formatFileSize(file.size)} + + + +
+ + ) : ( +
+ File not uploaded +
+ )} +
+ ); +} diff --git a/front/src/components/ui/image-viewer/index.ts b/front/src/components/ui/image-viewer/index.ts new file mode 100644 index 0000000..52d66d1 --- /dev/null +++ b/front/src/components/ui/image-viewer/index.ts @@ -0,0 +1 @@ +export { ImageViewer } from './component'; diff --git a/front/src/components/ui/image-viewer/styles.module.scss b/front/src/components/ui/image-viewer/styles.module.scss new file mode 100644 index 0000000..87d1617 --- /dev/null +++ b/front/src/components/ui/image-viewer/styles.module.scss @@ -0,0 +1,56 @@ +@use '@components/func.scss' as f; + +.viewer { + display: grid; + overflow: hidden; + box-shadow: 0px 2px 2px var(--clr-shadow-100); + grid-template-columns: 1fr; +} + +.placeholder { + display: flex; + justify-content: center; + background-color: var(--clr-layer-300); +} + +.image { + max-width: 100%; + max-height: 100%; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--clr-layer-300); +} + +$padding: 10px 16px; +$border-radius: 8px; + +.s { + border-radius: $border-radius; + + .placeholder, + .footer { + padding: $padding; + } +} + +.m { + border-radius: f.m($border-radius); + + .placeholder, + .footer { + padding: f.m($padding); + } +} + +.l { + border-radius: f.l($border-radius); + + .placeholder, + .footer { + padding: f.l($padding); + } +} diff --git a/front/src/components/ui/image-viewer/types.ts b/front/src/components/ui/image-viewer/types.ts new file mode 100644 index 0000000..2b44ce3 --- /dev/null +++ b/front/src/components/ui/image-viewer/types.ts @@ -0,0 +1,7 @@ +import { Scale } from '../types'; + +export type ImageViewerProps = { + file?: File; + scale?: Scale; + onClear?: () => void; +}; diff --git a/front/src/components/ui/index.tsx b/front/src/components/ui/index.ts similarity index 54% rename from front/src/components/ui/index.tsx rename to front/src/components/ui/index.ts index e485f62..9831603 100644 --- a/front/src/components/ui/index.tsx +++ b/front/src/components/ui/index.ts @@ -1,13 +1,23 @@ +export { Autocomplete } from './autocomplete'; export { Button } from './button'; export { Checkbox } from './checkbox'; export { CheckboxGroup } from './checkbox-group'; export { DateInput } from './date-input'; +export { Dialog } from './dialog'; +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 { NumberField } from './number-field'; +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'; export { Select } from './select'; export { Span } from './span'; +export { TextArea } from './text-area'; export { TextInput } from './text-input'; diff --git a/front/src/components/ui/input/component.tsx b/front/src/components/ui/input/component.tsx index 97921b6..8eeb60c 100644 --- a/front/src/components/ui/input/component.tsx +++ b/front/src/components/ui/input/component.tsx @@ -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); diff --git a/front/src/components/ui/input/styles.module.scss b/front/src/components/ui/input/styles.module.scss index c4d2fc2..30d43f3 100644 --- a/front/src/components/ui/input/styles.module.scss +++ b/front/src/components/ui/input/styles.module.scss @@ -1,3 +1,5 @@ +@use '@components/func.scss' as f; + .wrapper { display: flex; align-items: center; @@ -12,6 +14,10 @@ } } +.invalid { + border-color: var(--clr-error); +} + .wrapperFocus { z-index: 1; border-color: var(--clr-primary); @@ -26,29 +32,33 @@ outline: none; } +$border-radius: 8px; +$padding: 9px; +$font-size: 12px; + .s { - border-radius: 8px; + border-radius: $border-radius; .input { - padding: 9px; - font-size: 12px; + padding: $padding; + font-size: $font-size; } } .m { - border-radius: 10px; + border-radius: f.m($border-radius); .input { - padding: 13px; - font-size: 16px; + padding: f.m($padding); + font-size: f.m($font-size); } } .l { - border-radius: 12px; + border-radius: f.l($border-radius); .input { - padding: 17px; - font-size: 20px; + padding: f.l($padding); + font-size: f.l($font-size); } } diff --git a/front/src/components/ui/input/types.ts b/front/src/components/ui/input/types.ts index 4b88da0..8a4a0cc 100644 --- a/front/src/components/ui/input/types.ts +++ b/front/src/components/ui/input/types.ts @@ -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 }; diff --git a/front/src/components/ui/label/component.tsx b/front/src/components/ui/label/component.tsx index 4802220..8741eb8 100644 --- a/front/src/components/ui/label/component.tsx +++ b/front/src/components/ui/label/component.tsx @@ -11,6 +11,7 @@ function LabelInner( scale, position = 'top', required = {}, + error, className, children, ...props @@ -29,6 +30,11 @@ function LabelInner( )} {!reversed && children} + {error && ( + + {error} + + )} ); } diff --git a/front/src/components/ui/label/styles.module.scss b/front/src/components/ui/label/styles.module.scss index 184cd50..e8a5779 100644 --- a/front/src/components/ui/label/styles.module.scss +++ b/front/src/components/ui/label/styles.module.scss @@ -1,5 +1,9 @@ .label { display: flex; + + .error { + color: var(--clr-error); + } } .left, diff --git a/front/src/components/ui/label/types.ts b/front/src/components/ui/label/types.ts index a3113e4..4360229 100644 --- a/front/src/components/ui/label/types.ts +++ b/front/src/components/ui/label/types.ts @@ -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'>; diff --git a/front/src/components/ui/link-button/component.tsx b/front/src/components/ui/link-button/component.tsx new file mode 100644 index 0000000..ce6207a --- /dev/null +++ b/front/src/components/ui/link-button/component.tsx @@ -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 ( + + {children} + + + ); +} diff --git a/front/src/components/ui/link-button/index.tsx b/front/src/components/ui/link-button/index.tsx new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/link-button/index.tsx @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/link-button/styles.module.scss b/front/src/components/ui/link-button/styles.module.scss new file mode 100644 index 0000000..f477aea --- /dev/null +++ b/front/src/components/ui/link-button/styles.module.scss @@ -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); +} diff --git a/front/src/components/ui/link-button/types.ts b/front/src/components/ui/link-button/types.ts new file mode 100644 index 0000000..e9e0d95 --- /dev/null +++ b/front/src/components/ui/link-button/types.ts @@ -0,0 +1,7 @@ +import { ComponentPropsWithoutRef } from 'react'; + +import { Scale } from '../types'; + +export type LinkButtonProps = { + scale?: Scale; +} & ComponentPropsWithoutRef<'a'>; diff --git a/front/src/components/ui/menu/types.ts b/front/src/components/ui/menu/types.ts index bcc3787..5c22e09 100644 --- a/front/src/components/ui/menu/types.ts +++ b/front/src/components/ui/menu/types.ts @@ -1,7 +1,9 @@ +import { ComponentProps, Key } from 'react'; + export type MenuProps = { options: T[]; selected?: T; - getOptionKey: (option: T) => React.Key; + getOptionKey: (option: T) => Key; getOptionLabel: (option: T) => string; onSelect?: (option: T) => void; -} & Omit, 'onSelect'>; +} & Omit, 'onSelect'>; diff --git a/front/src/components/ui/number-field/component.tsx b/front/src/components/ui/number-field/component.tsx new file mode 100644 index 0000000..e9adcb3 --- /dev/null +++ b/front/src/components/ui/number-field/component.tsx @@ -0,0 +1,28 @@ +import React, { ForwardedRef, forwardRef } from 'react'; + +import { Label, LabelProps } from '../label'; +import { NumberInput } from '../number-input'; +import { NumberFieldProps } from './types'; + +function NumberFieldInner( + { scale, label = {}, required, ...props }: Omit, + ref: ForwardedRef, +) { + const labelProps: LabelProps = { + ...label, + required: { value: required, ...label.required }, + }; + return ( + + ); +} + +export const NumberField = forwardRef(NumberFieldInner); diff --git a/front/src/components/ui/number-field/index.tsx b/front/src/components/ui/number-field/index.tsx new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/number-field/index.tsx @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/number-field/types.ts b/front/src/components/ui/number-field/types.ts new file mode 100644 index 0000000..0050137 --- /dev/null +++ b/front/src/components/ui/number-field/types.ts @@ -0,0 +1,6 @@ +import { LabelProps } from '../label/types'; +import { NumberInputProps } from '../number-input/types'; + +export type NumberFieldProps = { + label?: LabelProps; +} & NumberInputProps; diff --git a/front/src/components/ui/number-input/component.tsx b/front/src/components/ui/number-input/component.tsx new file mode 100644 index 0000000..c47bbdc --- /dev/null +++ b/front/src/components/ui/number-input/component.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Input } from '../input'; +import { NumberInputProps } from './types'; + +export function NumberInput({ + value, + onChange, + float = false, + negative = false, + ...props +}: NumberInputProps) { + const extractNumber = (event: React.ChangeEvent) => { + const { value } = event.target; + if (!value) { + return ''; + } + let pattern = ''; + if (negative) { + pattern += '-?'; + } + pattern += '\\d*'; + if (float) { + pattern += '\\.?\\d*'; + } + return value.match(pattern)?.[0] ?? null; + }; + + const handleChange = (event: React.ChangeEvent) => { + const num = extractNumber(event); + if (num === null) { + return; + } + onChange?.(num); + }; + + return ; +} diff --git a/front/src/components/ui/number-input/index.tsx b/front/src/components/ui/number-input/index.tsx new file mode 100644 index 0000000..101f010 --- /dev/null +++ b/front/src/components/ui/number-input/index.tsx @@ -0,0 +1 @@ +export { NumberInput } from './component'; diff --git a/front/src/components/ui/number-input/preview.tsx b/front/src/components/ui/number-input/preview.tsx new file mode 100644 index 0000000..185c1ab --- /dev/null +++ b/front/src/components/ui/number-input/preview.tsx @@ -0,0 +1,39 @@ +import { PreviewArticle } from '@components/ui/preview'; +import React, { useState } from 'react'; + +import { NumberInput } from './component'; + +export function NumberInputPreview() { + const [value1, setValue1] = useState(''); + const [value2, setValue2] = useState(''); + const [value3, setValue3] = useState(''); + + return ( + + + + + + ); +} diff --git a/front/src/components/ui/number-input/types.ts b/front/src/components/ui/number-input/types.ts new file mode 100644 index 0000000..ea020c7 --- /dev/null +++ b/front/src/components/ui/number-input/types.ts @@ -0,0 +1,8 @@ +import { TextInputProps } from '../text-input'; + +export type NumberInputProps = { + float?: boolean; + negative?: boolean; + value?: string; + onChange?: (value: string) => void; +} & Omit; diff --git a/front/src/components/ui/overlay/component.tsx b/front/src/components/ui/overlay/component.tsx new file mode 100644 index 0000000..1d4952d --- /dev/null +++ b/front/src/components/ui/overlay/component.tsx @@ -0,0 +1,47 @@ +import clsx from 'clsx'; +import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import styles from './styles.module.scss'; +import { OverlayProps } from './types'; + +function OverlayInner( + { open, className, ...props }: OverlayProps, + ref: ForwardedRef, +) { + const [openInternal, setOpenInternal] = useState(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( +
, + document.body, + ); +} + +export const Overlay = forwardRef(OverlayInner); diff --git a/front/src/components/ui/overlay/index.tsx b/front/src/components/ui/overlay/index.tsx new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/overlay/index.tsx @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/overlay/styles.module.scss b/front/src/components/ui/overlay/styles.module.scss new file mode 100644 index 0000000..58ac456 --- /dev/null +++ b/front/src/components/ui/overlay/styles.module.scss @@ -0,0 +1,30 @@ +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100dvh; + animation: fadein 0.25s forwards ease-in-out; +} + +.closed { + animation: fadeout 0.25s forwards ease-in-out; +} + +@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); + } +} diff --git a/front/src/components/ui/overlay/types.ts b/front/src/components/ui/overlay/types.ts new file mode 100644 index 0000000..166871f --- /dev/null +++ b/front/src/components/ui/overlay/types.ts @@ -0,0 +1,5 @@ +import { ComponentPropsWithoutRef } from 'react'; + +export type OverlayProps = { + open: boolean; +} & ComponentPropsWithoutRef<'div'>; diff --git a/front/src/components/ui/pagination/component.tsx b/front/src/components/ui/pagination/component.tsx new file mode 100644 index 0000000..2bec239 --- /dev/null +++ b/front/src/components/ui/pagination/component.tsx @@ -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 ( +
+ + + + {pageNumbers.map((number, index) => { + if (number === null) { + return ( + + ... + + ); + } + const isCurrent = number === value; + return ( + onChange?.(number)} + > + {number} + + ); + })} + + + +
+ ); +} diff --git a/front/src/components/ui/pagination/components/index.ts b/front/src/components/ui/pagination/components/index.ts new file mode 100644 index 0000000..ea4561b --- /dev/null +++ b/front/src/components/ui/pagination/components/index.ts @@ -0,0 +1 @@ +export * from './pagination-item'; diff --git a/front/src/components/ui/pagination/components/pagination-item/component.tsx b/front/src/components/ui/pagination/components/pagination-item/component.tsx new file mode 100644 index 0000000..8e27bf5 --- /dev/null +++ b/front/src/components/ui/pagination/components/pagination-item/component.tsx @@ -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 ( + + {children} + + + ); +} diff --git a/front/src/components/ui/pagination/components/pagination-item/index.ts b/front/src/components/ui/pagination/components/pagination-item/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/pagination/components/pagination-item/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/pagination/components/pagination-item/styles.module.scss b/front/src/components/ui/pagination/components/pagination-item/styles.module.scss new file mode 100644 index 0000000..b78bdf0 --- /dev/null +++ b/front/src/components/ui/pagination/components/pagination-item/styles.module.scss @@ -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); + } +} diff --git a/front/src/components/ui/pagination/components/pagination-item/types.ts b/front/src/components/ui/pagination/components/pagination-item/types.ts new file mode 100644 index 0000000..a2ea2fa --- /dev/null +++ b/front/src/components/ui/pagination/components/pagination-item/types.ts @@ -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'>; diff --git a/front/src/components/ui/pagination/index.ts b/front/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..1d89bd8 --- /dev/null +++ b/front/src/components/ui/pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './component'; +export { PaginationPreview } from './preview'; diff --git a/front/src/components/ui/pagination/preview.tsx b/front/src/components/ui/pagination/preview.tsx new file mode 100644 index 0000000..1c57082 --- /dev/null +++ b/front/src/components/ui/pagination/preview.tsx @@ -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(1); + const [value2, setValue2] = useState(1); + const [value3, setValue3] = useState(1); + + return ( + + + + + + ); +} diff --git a/front/src/components/ui/pagination/styles.module.scss b/front/src/components/ui/pagination/styles.module.scss new file mode 100644 index 0000000..50ed4da --- /dev/null +++ b/front/src/components/ui/pagination/styles.module.scss @@ -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); +} diff --git a/front/src/components/ui/pagination/types.ts b/front/src/components/ui/pagination/types.ts new file mode 100644 index 0000000..fde3f63 --- /dev/null +++ b/front/src/components/ui/pagination/types.ts @@ -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; +}; diff --git a/front/src/components/ui/pagination/utils.ts b/front/src/components/ui/pagination/utils.ts new file mode 100644 index 0000000..e5a7b11 --- /dev/null +++ b/front/src/components/ui/pagination/utils.ts @@ -0,0 +1,30 @@ +export const getPageNumbers = ( + page: number, + total: number, + sibling: number, + boundary: number, +): number[] => { + const pages: number[] = []; + const visible = Math.min(total, (boundary + sibling + 1) * 2 + 1); + + const isOverflow = visible !== total; + const isLeftOverflow = isOverflow && page > boundary + sibling + 2; + const isRightOverflow = isOverflow && total - page > boundary + sibling + 1; + + let cursor = 1; + + for (let i = 1; i <= visible; i += 1) { + if (i === boundary + 1 && isLeftOverflow) { + pages.push(null); + cursor = Math.min(page - sibling, total - boundary - sibling * 2 - 1); + } else if (i === visible - boundary && isRightOverflow) { + pages.push(null); + cursor = total - boundary + 1; + } else { + pages.push(cursor); + cursor += 1; + } + } + + return pages; +}; diff --git a/front/src/components/ui/paragraph/styles.module.scss b/front/src/components/ui/paragraph/styles.module.scss index 03d0c00..466d030 100644 --- a/front/src/components/ui/paragraph/styles.module.scss +++ b/front/src/components/ui/paragraph/styles.module.scss @@ -1,17 +1,21 @@ +@use '@components/func.scss' as f; + .paragraph { margin: 0; } +$font-size: 12px; + .s { - font-size: 12px; + font-size: $font-size; } .m { - font-size: 16px; + font-size: f.m($font-size); } .l { - font-size: 20px; + font-size: f.l($font-size); } .t100 { diff --git a/front/src/components/ui/paragraph/types.ts b/front/src/components/ui/paragraph/types.ts index 8aa96c0..44071b1 100644 --- a/front/src/components/ui/paragraph/types.ts +++ b/front/src/components/ui/paragraph/types.ts @@ -1,8 +1,10 @@ +import { ComponentPropsWithoutRef } from 'react'; + import { Scale, TextColor } from '../types'; type ParagraphProps = { scale?: Scale; color?: TextColor; -} & React.ComponentPropsWithoutRef<'p'>; +} & ComponentPropsWithoutRef<'p'>; export { ParagraphProps }; diff --git a/front/src/components/ui/popover/component.tsx b/front/src/components/ui/popover/component.tsx index 254b47f..351dfd6 100644 --- a/front/src/components/ui/popover/component.tsx +++ b/front/src/components/ui/popover/component.tsx @@ -10,8 +10,16 @@ import { createPortal } from 'react-dom'; import { Fade } from '../animation'; import styles from './styles.module.scss'; import { PopoverProps } from './types'; +import { calcFadeStyles } from './utils'; -export function Popover({ element, visible, calcStyles }: PopoverProps) { +export function Popover({ + visible, + anchorRef, + position, + horizontalAlign, + element, + flip = false, +}: PopoverProps) { const elementRef = useRef(null); const fadeRef = useRef(null); const [elementRect, setElementRect] = useState(null); @@ -25,7 +33,14 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) { return; } const updateStyles = () => { - Object.assign(fadeRef.current.style, calcStyles(elementRect)); + const style = calcFadeStyles( + elementRect, + anchorRef, + position, + horizontalAlign, + flip, + ); + Object.assign(fadeRef.current.style, style); }; window.addEventListener('scroll', updateStyles, true); window.addEventListener('resize', updateStyles); @@ -36,7 +51,10 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) { }, [visible]); if (elementRect === null) { - return cloneElement(element, { ref: elementRef }); + return cloneElement(element, { + ref: elementRef, + style: { position: 'absolute' }, + }); } return createPortal( @@ -44,7 +62,13 @@ export function Popover({ element, visible, calcStyles }: PopoverProps) { visible={visible} className={styles.fade} ref={fadeRef} - style={{ ...calcStyles(elementRect) }} + style={calcFadeStyles( + elementRect, + anchorRef, + position, + horizontalAlign, + flip, + )} > {element} , diff --git a/front/src/components/ui/popover/index.ts b/front/src/components/ui/popover/index.ts deleted file mode 100644 index 192728d..0000000 --- a/front/src/components/ui/popover/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Popover } from './component'; -export { type PopoverProps, type PopoverStyles } from './types'; diff --git a/front/src/components/ui/popover/index.tsx b/front/src/components/ui/popover/index.tsx new file mode 100644 index 0000000..31eec59 --- /dev/null +++ b/front/src/components/ui/popover/index.tsx @@ -0,0 +1 @@ +export { Popover } from './component'; diff --git a/front/src/components/ui/popover/styles.module.scss b/front/src/components/ui/popover/styles.module.scss index 4bb9de8..3614187 100644 --- a/front/src/components/ui/popover/styles.module.scss +++ b/front/src/components/ui/popover/styles.module.scss @@ -1,5 +1,4 @@ .fade { position: absolute; - top: 0; - left: 0; + z-index: 2; } diff --git a/front/src/components/ui/popover/types.ts b/front/src/components/ui/popover/types.ts index d2a30d4..17c15a7 100644 --- a/front/src/components/ui/popover/types.ts +++ b/front/src/components/ui/popover/types.ts @@ -1,13 +1,14 @@ -import { ReactElement } from 'react'; +import { MutableRefObject, ReactElement } from 'react'; -export type PopoverStyles = { - left?: string; - top?: string; - width?: string; -}; +export type PopoverPosition = 'top' | 'bottom'; + +export type PopoverHorizontalAlign = 'left' | 'right' | 'center' | 'stretch'; export type PopoverProps = { - element: ReactElement; visible: boolean; - calcStyles: (elementRect: DOMRect | null) => PopoverStyles; + anchorRef: MutableRefObject; + position: PopoverPosition; + horizontalAlign: PopoverHorizontalAlign; + element: ReactElement; + flip?: boolean; }; diff --git a/front/src/components/ui/popover/utils.ts b/front/src/components/ui/popover/utils.ts new file mode 100644 index 0000000..7ceb356 --- /dev/null +++ b/front/src/components/ui/popover/utils.ts @@ -0,0 +1,92 @@ +import { px } from '@utils/css'; +import { CSSProperties, MutableRefObject } from 'react'; + +import { PopoverHorizontalAlign, PopoverPosition } from './types'; + +const applyPositionTop = ( + anchorRect: DOMRect, + elementRect: DOMRect, + styles: CSSProperties, +) => { + styles.top = px(anchorRect.bottom - anchorRect.height - elementRect.height); +}; + +const applyPositionBottom = (anchorRect: DOMRect, styles: CSSProperties) => { + styles.top = px(anchorRect.bottom); +}; + +const applyPosition = ( + elementRect: DOMRect, + anchorRect: DOMRect, + position: PopoverPosition, + flip: boolean, + styles: CSSProperties, +) => { + if (position === 'bottom') { + if (flip) { + const bottomSpace = window.innerHeight - anchorRect.bottom; + if (bottomSpace >= elementRect.height) { + applyPositionBottom(anchorRect, styles); + } else { + applyPositionTop(anchorRect, elementRect, styles); + } + } else { + applyPositionBottom(anchorRect, styles); + } + } + + if (position === 'top') { + if (flip) { + const topSpace = anchorRect.top; + if (topSpace >= elementRect.height) { + applyPositionTop(anchorRect, elementRect, styles); + } else { + applyPositionBottom(anchorRect, styles); + } + } else { + applyPositionTop(anchorRect, elementRect, styles); + } + } +}; + +const applyHorizontalAlign = ( + elementRect: DOMRect, + anchorRect: DOMRect, + horizontalAlign: PopoverHorizontalAlign, + styles: CSSProperties, +) => { + if (horizontalAlign === 'left') { + styles.left = px(anchorRect.left); + } + + if (horizontalAlign === 'right') { + styles.left = px(anchorRect.left + anchorRect.width - elementRect.width); + } + + if (horizontalAlign === 'center') { + styles.left = px( + anchorRect.left + (anchorRect.width - elementRect.width) / 2, + ); + } + + if (horizontalAlign === 'stretch') { + styles.left = px(anchorRect.left); + styles.width = px(anchorRect.width); + } +}; + +export const calcFadeStyles = ( + elementRect: DOMRect, + anchorRef: MutableRefObject, + position: PopoverPosition, + horizontalAlign: PopoverHorizontalAlign, + flip: boolean, +): CSSProperties => { + const anchorRect = anchorRef.current.getBoundingClientRect(); + const styles: CSSProperties = {}; + + applyPosition(elementRect, anchorRect, position, flip, styles); + applyHorizontalAlign(elementRect, anchorRect, horizontalAlign, styles); + + return styles; +}; diff --git a/front/src/components/ui/preview/preview-article/types.ts b/front/src/components/ui/preview/preview-article/types.ts index 5ed3e57..b7ab9f6 100644 --- a/front/src/components/ui/preview/preview-article/types.ts +++ b/front/src/components/ui/preview/preview-article/types.ts @@ -1,3 +1,5 @@ +import { ComponentProps } from 'react'; + export type PreviewArticleProps = { title: string; -} & React.ComponentProps<'div'>; +} & ComponentProps<'div'>; diff --git a/front/src/components/ui/preview/preview-box/types.ts b/front/src/components/ui/preview/preview-box/types.ts index 3663ae7..925bb31 100644 --- a/front/src/components/ui/preview/preview-box/types.ts +++ b/front/src/components/ui/preview/preview-box/types.ts @@ -1 +1,3 @@ -export type PreviewBoxProps = {} & React.ComponentProps<'div'>; +import { ComponentProps } from 'react'; + +export type PreviewBoxProps = {} & ComponentProps<'div'>; diff --git a/front/src/components/ui/radio-group/styles.module.scss b/front/src/components/ui/radio-group/styles.module.scss index b1533e6..2393402 100644 --- a/front/src/components/ui/radio-group/styles.module.scss +++ b/front/src/components/ui/radio-group/styles.module.scss @@ -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); } } diff --git a/front/src/components/ui/radio-group/types.ts b/front/src/components/ui/radio-group/types.ts index 61939dd..416318a 100644 --- a/front/src/components/ui/radio-group/types.ts +++ b/front/src/components/ui/radio-group/types.ts @@ -1,3 +1,5 @@ +import { Key } from 'react'; + import { Scale } from '../types'; export type RadioGroupProps = { @@ -5,7 +7,7 @@ export type RadioGroupProps = { value: T; items: T[]; onChange: (value: T) => void; - getItemKey: (item: T) => React.Key; + getItemKey: (item: T) => Key; getItemLabel: (item: T) => string; scale?: Scale; label?: string; diff --git a/front/src/components/ui/radio/styles.module.scss b/front/src/components/ui/radio/styles.module.scss index d3be59c..38845ea 100644 --- a/front/src/components/ui/radio/styles.module.scss +++ b/front/src/components/ui/radio/styles.module.scss @@ -1,3 +1,5 @@ +@use '@components/func.scss' as f; + .wrapper { position: relative; overflow: hidden; @@ -55,32 +57,36 @@ } } +$padding-outer: 4px; +$size: 16px; +$padding-inner: 4px; + .s { - padding: 3px; + padding: $padding-outer; .radio { - width: 16px; - height: 16px; - padding: 4px; + width: $size; + height: $size; + padding: $padding-inner; } } .m { - padding: 4px; + padding: f.m($padding-outer); .radio { - width: 20px; - height: 20px; - padding: 5px; + width: f.m($size); + height: f.m($size); + padding: f.m($padding-inner); } } .l { - padding: 5px; + padding: f.l($padding-outer); .radio { - width: 24px; - height: 24px; - padding: 6px; + width: f.l($size); + height: f.l($size); + padding: f.l($padding-inner); } } diff --git a/front/src/components/ui/raw/raw-button/styles.module.scss b/front/src/components/ui/raw/raw-button/styles.module.scss index 7c7e305..68d9190 100644 --- a/front/src/components/ui/raw/raw-button/styles.module.scss +++ b/front/src/components/ui/raw/raw-button/styles.module.scss @@ -3,4 +3,8 @@ border: none; background-color: transparent; font: inherit; + + &:disabled { + pointer-events: none; + } } diff --git a/front/src/components/ui/raw/raw-button/types.ts b/front/src/components/ui/raw/raw-button/types.ts index 5cfecd4..a418e27 100644 --- a/front/src/components/ui/raw/raw-button/types.ts +++ b/front/src/components/ui/raw/raw-button/types.ts @@ -1 +1,3 @@ -export type RawButtonProps = {} & React.ComponentProps<'button'>; +import { ComponentProps } from 'react'; + +export type RawButtonProps = {} & ComponentProps<'button'>; diff --git a/front/src/components/ui/raw/raw-input/types.ts b/front/src/components/ui/raw/raw-input/types.ts index c47878a..452da97 100644 --- a/front/src/components/ui/raw/raw-input/types.ts +++ b/front/src/components/ui/raw/raw-input/types.ts @@ -1 +1,3 @@ -export type RawInputProps = {} & React.ComponentProps<'input'>; +import { ComponentProps } from 'react'; + +export type RawInputProps = {} & ComponentProps<'input'>; diff --git a/front/src/components/ui/select/component.tsx b/front/src/components/ui/select/component.tsx index 1c60248..d6e271c 100644 --- a/front/src/components/ui/select/component.tsx +++ b/front/src/components/ui/select/component.tsx @@ -1,5 +1,4 @@ import ArrowDownIcon from '@public/images/svg/arrow-down.svg'; -import { px } from '@utils/css'; import { useMissClick } from '@utils/miss-click'; import clsx from 'clsx'; import React, { @@ -37,7 +36,11 @@ function SelectInner( useImperativeHandle(ref, () => selectRef.current, []); - useMissClick([selectRef, menuRef], () => setMenuVisible(false), menuVisible); + useMissClick({ + callback: () => setMenuVisible(false), + enabled: menuVisible, + whitelist: [selectRef, menuRef], + }); const selectClassName = clsx(styles.select, styles[scale], { [styles.menuVisible]: menuVisible, @@ -52,25 +55,6 @@ function SelectInner( onChange?.(option); }; - const calcPopoverStyles = (menuRect: DOMRect) => { - if (menuRect === null) { - return {}; - } - - const inputWrapperRect = inputWrapperRef.current.getBoundingClientRect(); - const { width, left, bottom, top } = inputWrapperRect; - - const bottomSpace = window.innerHeight - bottom; - const popoverTop = - bottomSpace >= menuRect.height ? bottom : top - menuRect.height; - - return { - width: px(width), - left: px(left), - top: px(popoverTop), - }; - }; - return (
( /> = { 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, 'onChange'>; +} & Omit, 'onChange'>; diff --git a/front/src/components/ui/span/styles.module.scss b/front/src/components/ui/span/styles.module.scss index 08059b0..71d04e6 100644 --- a/front/src/components/ui/span/styles.module.scss +++ b/front/src/components/ui/span/styles.module.scss @@ -1,13 +1,17 @@ +@use '@components/func.scss' as f; + +$font-size: 12px; + .s { - font-size: 12px; + font-size: $font-size; } .m { - font-size: 16px; + font-size: f.m($font-size); } .l { - font-size: 20px; + font-size: f.l($font-size); } .t100 { diff --git a/front/src/components/ui/span/types.ts b/front/src/components/ui/span/types.ts index b86af5f..1c28afd 100644 --- a/front/src/components/ui/span/types.ts +++ b/front/src/components/ui/span/types.ts @@ -1,8 +1,10 @@ +import { ComponentPropsWithoutRef } from 'react'; + import { Scale, TextColor } from '../types'; type SpanProps = { scale?: Scale; color?: TextColor; -} & React.ComponentPropsWithoutRef<'span'>; +} & ComponentPropsWithoutRef<'span'>; export { SpanProps }; diff --git a/front/src/components/ui/text-area/component.tsx b/front/src/components/ui/text-area/component.tsx new file mode 100644 index 0000000..f588e85 --- /dev/null +++ b/front/src/components/ui/text-area/component.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { Label } from '../label'; +import styles from './styles.module.scss'; +import { TextAreaProps } from './types'; + +export function TextArea({ + scale = 'm', + label = {}, + className, + ...props +}: TextAreaProps) { + const textAreaClassName = clsx(styles.textarea, styles[scale], className); + return ( +