Compare commits

..

5 Commits

264 changed files with 1204 additions and 4499 deletions

View File

@ -1,11 +1,10 @@
from PyWeather.weather.stations.davis import VantagePro
import logging
import time
import mariadb
import serial.tools.list_ports
import gc
import time
from pprint import pprint
from PyWeather.weather.stations.davis import VantagePro
logging.basicConfig(filename="Stations.log",
format='%(asctime)s %(message)s',
@ -13,37 +12,10 @@ logging.basicConfig(filename="Stations.log",
logger = logging.getLogger('davis_api')
logger.setLevel(logging.DEBUG)
def write_data(device, station, send=True):
try:
#device.parse()
data = device.fields
print(data)
if len(data) < 1:
return
else:
print(data)
fields = ['BarTrend', 'CRC', 'DateStamp', 'DewPoint', 'HeatIndex', 'ETDay', 'HeatIndex',
'HumIn', 'HumOut', 'Pressure', 'RainDay', 'RainMonth', 'RainRate', 'RainStorm',
'RainYear', 'SunRise', 'SunSet', 'TempIn', 'TempOut', 'WindDir', 'WindSpeed',
'WindSpeed10Min']
if send:
placeholders = ', '.join(['%s'] * len(fields))
field_names = ', '.join(fields)
sql = f"INSERT INTO weather_data ({field_names}) VALUES ({placeholders})"
values = [data[field] for field in fields]
cursor.execute(sql, values)
conn.commit()
else:
pprint(data)
del data
del fields
gc.collect()
except Exception as e:
logger.error(str(e))
raise e
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
logger.addHandler(console_handler)
try:
conn = mariadb.connect(
@ -57,23 +29,25 @@ try:
except mariadb.Error as e:
logger.error('DB_ERR: ' + str(e))
raise e
while True:
try:
ports = serial.tools.list_ports.comports()
available_ports = {}
try:
ports = serial.tools.list_ports.comports()
available_ports = {}
for port in ports:
if port.serial_number == '0001':
available_ports[port.name] = port.vid
for port in ports:
if port.serial_number == '0001':
available_ports[port.name] = port.vid
devices = [VantagePro(port) for port in available_ports.keys()]
while True:
for i in range(1):
if len(devices) != 0:
logger.info(devices)
else:
raise Exception('Can`t connect to device')
time.sleep(60)
except Exception as e:
logger.error('Device_error' + str(e))
time.sleep(60)
devices = [VantagePro(port) for port in available_ports.keys()]
print(available_ports)
while True:
for i in range(len(devices)):
print(devices[i].fields)
#write_data(devices[i], 'st' + str(available_ports[list(available_ports.keys())[i]]), True)
time.sleep(1)
except Exception as e:
logger.error('Device_error: ' + str(e))
raise e
# todo переписать под influx, для линухи приколы сделать

185
davisAPI/prediction.py Normal file
View File

@ -0,0 +1,185 @@
from datetime import datetime
from pathlib import Path
import metpy.calc
import numpy as np
import requests
import torch
import xarray as xr
from aurora import AuroraSmall, Batch, Metadata
from metpy.units import units
def get_download_paths(date):
"""Создает список путей для загрузки данных."""
download_path = Path("~/downloads/hres_0.1").expanduser()
downloads = {}
var_nums = {
"2t": "167", "10u": "165", "10v": "166", "msl": "151", "t": "130",
"u": "131", "v": "132", "q": "133", "z": "129", "slt": "043", "lsm": "172",
}
for v in ["2t", "10u", "10v", "msl", "z", "slt", "lsm"]:
downloads[download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.sfc/{date.year}{date.month:02d}/ec.oper.an.sfc.128_{var_nums[v]}_{v}."
f"regn1280sc.{date.year}{date.month:02d}{date.day:02d}.grb"
)
for v in ["z", "t", "u", "v", "q"]:
for hour in [0, 6, 12, 18]:
prefix = "uv" if v in {"u", "v"} else "sc"
downloads[download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_{hour:02d}.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.pl/{date.year}{date.month:02d}/ec.oper.an.pl.128_{var_nums[v]}_{v}."
f"regn1280{prefix}.{date.year}{date.month:02d}{date.day:02d}{hour:02d}.grb"
)
return downloads, download_path
def download_data(downloads):
"""Скачивает файлы, если они отсутствуют в целевой директории."""
for target, source in downloads.items():
if not target.exists():
print(f"Downloading {source}")
target.parent.mkdir(parents=True, exist_ok=True)
response = requests.get(source)
response.raise_for_status()
with open(target, "wb") as f:
f.write(response.content)
print("Downloads finished!")
def load_surf(v, v_in_file, download_path, date):
"""Загружает переменные поверхностного уровня или статические переменные."""
ds = xr.open_dataset(download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib"), engine="cfgrib")
data = ds[v_in_file].values[:2]
data = data[None]
return torch.from_numpy(data)
def load_atmos(v, download_path, date, levels):
"""Загружает атмосферные переменные для заданных уровней давления."""
ds_00 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_00.grib"), engine="cfgrib"
)
ds_06 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_06.grib"), engine="cfgrib"
)
ds_00 = ds_00[v].sel(isobaricInhPa=list(levels))
ds_06 = ds_06[v].sel(isobaricInhPa=list(levels))
data = np.stack((ds_00.values, ds_06.values), axis=0)
data = data[None]
return torch.from_numpy(data)
def create_batch(date, levels, downloads, download_path):
"""Создает объект Batch с данными для модели."""
ds = xr.open_dataset(next(iter(downloads.keys())), engine="cfgrib")
batch = Batch(
surf_vars={
"2t": load_surf("2t", "t2m", download_path, date),
"10u": load_surf("10u", "u10", download_path, date),
"10v": load_surf("10v", "v10", download_path, date),
"msl": load_surf("msl", "msl", download_path, date),
},
static_vars={
"z": load_surf("z", "z", download_path, date)[0, 0],
"slt": load_surf("slt", "slt", download_path, date)[0, 0],
"lsm": load_surf("lsm", "lsm", download_path, date)[0, 0],
},
atmos_vars={
"t": load_atmos("t", download_path, date, levels),
"u": load_atmos("u", download_path, date, levels),
"v": load_atmos("v", download_path, date, levels),
"q": load_atmos("q", download_path, date, levels),
"z": load_atmos("z", download_path, date, levels),
},
metadata=Metadata(
lat=torch.from_numpy(ds.latitude.values),
lon=torch.from_numpy(ds.longitude.values),
time=(date.replace(hour=6),),
atmos_levels=levels,
),
)
return batch.regrid(res=0.1)
def create_batch_random(levels: tuple[int], date: datetime):
"""Создает объект Batch с рандомными данными для модели."""
return Batch(
surf_vars={k: torch.randn(1, 2, 17, 32) for k in ("2t", "10u", "10v", "msl")},
static_vars={k: torch.randn(17, 32) for k in ("lsm", "z", "slt")},
atmos_vars={k: torch.randn(1, 2, 4, 17, 32) for k in ("z", "u", "v", "t", "q")},
metadata=Metadata(
lat=torch.linspace(90, -90, 17),
lon=torch.linspace(0, 360, 32 + 1)[:-1],
time=(date,),
atmos_levels=levels,
),
)
def run_model(batch):
"""Инициализирует модель AuroraSmall и выполняет предсказание."""
model = AuroraSmall()
model.load_checkpoint("microsoft/aurora", "aurora-0.25-small-pretrained.ckpt")
model.eval()
model = model.to("cpu")
with torch.inference_mode():
prediction = model.forward(batch)
return prediction
def get_wind_speed_and_direction(prediction, batch: Batch, lat: float, lon: float):
target_lat = lat
target_lon = lon
lat_idx = torch.abs(batch.metadata.lat - target_lat).argmin()
lon_idx = torch.abs(batch.metadata.lon - target_lon).argmin()
u_values = prediction.atmos_vars["u"][:, :, :, lat_idx, lon_idx]
v_values = prediction.atmos_vars["v"][:, :, :, lat_idx, lon_idx]
u_scalar = u_values.item()
v_scalar = v_values.item()
print("u value:", u_scalar)
print("v value:", v_scalar)
u_with_units = u_scalar * units("m/s")
v_with_units = v_scalar * units("m/s")
# Рассчитайте направление и скорость ветра
wind_dir = metpy.calc.wind_direction(u_with_units, v_with_units)
wind_speed = metpy.calc.wind_speed(u_with_units, v_with_units)
wind_dir_text = wind_direction_to_text(wind_dir.magnitude)
# Вывод результата
print(f"Направление ветра: {wind_dir_text} ({wind_dir:.2f}°)")
print(f"Скорость ветра: {wind_speed:.2f} м/с")
return wind_dir.magnitude.item(), wind_speed.magnitude.item()
def wind_direction_to_text(wind_dir_deg):
directions = [
"север", "северо-восток", "восток", "юго-восток",
"юг", "юго-запад", "запад", "северо-запад"
]
idx = int((wind_dir_deg + 22.5) // 45) % 8
return directions[idx]
def main():
levels = (100,)
date = datetime(2024, 11, 5, 12)
# downloads, download_path = get_download_paths(date)
# download_data(downloads) # Скачиваем данные, если их нет
# batch_actual = create_batch(date, levels, downloads, download_path)
batch_actual = create_batch_random(levels, date)
prediction_actual = run_model(batch_actual)
wind_speed_and_direction = get_wind_speed_and_direction(prediction_actual, batch_actual, 50, 20)
return wind_speed_and_direction
if __name__ == "__main__":
main()
print("Prediction completed!")

View File

@ -2811,7 +2811,7 @@
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"dev": true
"devOptional": 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==",
"dev": true,
"devOptional": 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==",
"dev": true
"devOptional": true
},
"node_modules/debug": {
"version": "4.3.7",

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M3.51,3.01c0,0.13-0.05,0.26-0.15,0.35l-2,2c-0.2,0.2-0.51,0.2-0.71,0c-0.2-0.2-0.2-0.51,0-0.71l1.64-1.64L0.64,1.36
c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l2,2C3.46,2.75,3.51,2.88,3.51,3.01z"/>
</svg>

Before

Width:  |  Height:  |  Size: 558 B

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 914 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M9.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
<g>
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,51 +0,0 @@
import { BASE_URL } from './constants';
import { ApiResponse } from './types';
import { unpack } from './utils';
const send = async <T>(
url: string,
init: RequestInit,
): Promise<ApiResponse<T>> => {
const fullURL = `${BASE_URL}/${url}`;
const fullInit: RequestInit = { ...init };
try {
const response = await fetch(fullURL, fullInit);
if (!response.ok) {
return {
data: null,
error: { status: response.status, message: 'Something went wrong' },
};
}
const raw = await response.json();
const data: T = unpack(raw);
return { data: data, error: null };
} catch {
return {
data: null,
error: { status: 0, message: 'Something went wrong' },
};
}
};
export const api = {
get: async <T>(url: string) => {
return send<T>(url, { method: 'GET' });
},
post: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
put: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
delete: async <T>(url: string) => {
return send<T>(url, { method: 'DELETE' });
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
front/src/api/index.tsx Normal file
View File

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

View File

@ -1,9 +0,0 @@
export type ApiError = {
status: number;
message: string;
};
export type ApiResponse<T> = {
data: T | null;
error: ApiError | null;
};

View File

@ -1,23 +0,0 @@
export const toCamelCase = (str: string) => {
return str
.split(/[_\s-]+|(?=[A-Z])/)
.map((word, index) =>
index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join('');
};
export const unpack = (obj: unknown) => {
if (Array.isArray(obj)) {
return obj.map((item) => unpack(item));
} else if (obj !== null && typeof obj === 'object') {
return Object.entries(obj).reduce((acc, [key, value]) => {
const newKey = toCamelCase(key);
acc[newKey] = unpack(value);
return acc;
}, {});
}
return obj;
};

View File

@ -1,7 +0,0 @@
export const WIND_ENDPOINTS = {
turbines: 'api/wind/turbines',
turbineType: 'api/wind/turbine_type',
parks: 'api/wind/parks',
park: 'api/wind/park',
parkTurbine: 'api/wind/park_turbine',
};

View File

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

View File

@ -1,117 +0,0 @@
import { ApiResponse } from '@api/types';
import { ParkFormValues } from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
import { api } from '../api';
import { WIND_ENDPOINTS } from './constants';
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
import { packPark, packParkTurbine, packTurbineTypes } from './utils';
export const getTurbineTypes = () => {
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
};
export const getTurbineType = (id: string) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.get<TurbineType>(url);
};
export const createTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
) => {
return api.post<TurbineType>(
WIND_ENDPOINTS.turbineType,
packTurbineTypes(formValues),
);
};
export const editTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
id: string,
) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.put<TurbineType>(url, packTurbineTypes(formValues));
};
export const deleteTurbineType = (id: number) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.delete(url);
};
export const getParks = () => {
return api.get<Park[]>(WIND_ENDPOINTS.parks);
};
export const getPark = (id: string) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.get<Park>(url);
};
export const getParkTurbines = (id: string) => {
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
return api.get<ParkTurbine[]>(url);
};
export const getParkWithTurbines = async (
id: string,
): Promise<ApiResponse<ParkWithTurbines>> => {
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
const parkPesponse = await api.get<Park>(parkURL);
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
return {
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
error: parkPesponse.error || turbinesResponse.error || null,
};
};
export const createPark = async (formValues: Partial<ParkFormValues>) => {
const parkPesponse = await api.post<Park>(
WIND_ENDPOINTS.park,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(String(parkPesponse.data.id));
};
export const updatePark = async (
formValues: Partial<ParkFormValues>,
id: string,
) => {
const parkPesponse = await api.put<Park>(
`${WIND_ENDPOINTS.park}/${id}`,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
if (t.new) {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}
if (t.delete) {
return api.delete(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
);
}
return api.put(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(id);
};
export const deletePark = (id: number) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.delete(url);
};

View File

@ -1,32 +0,0 @@
export type TurbineType = {
id: number;
name: string;
height: number;
bladeLength: number;
};
export type Park = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
};
export type ParkTurbine = {
id: number;
name: string;
height: number;
bladeLength: number;
xOffset: number;
yOffset: number;
angle: number;
comment: string;
};
export type ParkWithTurbines = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
turbines: ParkTurbine[];
};

View File

@ -1,32 +0,0 @@
import {
ParkFormTurbine,
ParkFormValues,
} from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
return {
Name: values.name ?? '',
Height: parseInt(values.height || '0'),
BladeLength: parseInt(values.bladeLength || '0'),
};
};
export const packPark = (values: Partial<ParkFormValues>) => {
return {
Name: values.name ?? '',
CenterLatitude: parseInt(values.centerLatitude || '0'),
CenterLongitude: parseInt(values.centerLongitude || '0'),
};
};
export const packParkTurbine = (turbine: ParkFormTurbine, parkId: number) => {
return {
wind_park_id: parkId,
turbine_id: turbine.id,
x_offset: parseInt(turbine.xOffset || '0'),
y_offset: parseInt(turbine.yOffset || '0'),
angle: parseInt(turbine.angle || '0'),
comment: turbine.comment ?? '',
};
};

View File

@ -1,19 +0,0 @@
@function scale($values, $factor) {
@if type-of($values) == 'list' {
$m-values: ();
@each $value in $values {
$m-values: append($m-values, $value * $factor);
}
@return $m-values;
} @else {
@return nth($values, 1) * $factor;
}
}
@function m($values) {
@return scale($values, 1.25);
}
@function l($values) {
@return scale($values, 1.5);
}

View File

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

View File

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

View File

@ -1,35 +0,0 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import {
ParkPage,
ParksPage,
TurbineTypePage,
TurbineTypesPage,
} from '@components/pages';
import { ROUTES } from '@utils/route';
import React from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route
path={ROUTES.turbineTypes.path}
element={<TurbineTypesPage />}
/>
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
<Route path={ROUTES.parks.path} element={<ParksPage />} />
<Route path={ROUTES.park.path} element={<ParkPage />} />
</Route>
<Route
path="*"
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
/>
</Routes>
</BrowserRouter>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
import { Header, Sidebar } from '@components/ux';
import { useDeviceType } from '@utils/device';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
export function MainLayout() {
const deviceType = useDeviceType();
return (
<div className={styles.mainLayout}>
{deviceType === 'mobile' ? (
<Header className={styles.header} />
) : (
<Sidebar className={styles.sidebar} />
)}
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}

View File

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

View File

@ -1,38 +1,13 @@
@use '@components/mixins.scss' as m;
.mainLayout {
display: grid;
height: 100%;
grid-template:
'sidebar main' minmax(0, 1fr)
/ auto minmax(0, 1fr);
}
.sidebar {
grid-area: sidebar;
}
.header {
grid-area: header;
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
.main {
display: grid;
overflow: auto;
height: 100%;
grid-area: main;
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
& > * {
grid-column: 2;
}
}
@include m.on-mobile {
.mainLayout {
grid-template:
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,128 +0,0 @@
import {
createPark,
getParkWithTurbines,
ParkWithTurbines,
updatePark,
} from '@api/wind';
import {
Button,
Dialog,
Heading,
NumberField,
TextInput,
} from '@components/ui';
import { ParkTurbines } from '@components/ux';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { ParkFormValues } from './types';
import { unpackPark } from './utils';
export function ParkPage() {
const [park, setPark] = useState<ParkWithTurbines>(null);
const [pending, setPending] = useState<boolean>(false);
const [error, setError] = useState<string>(null);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchPark = async () => {
const response = await getParkWithTurbines(id);
setPark(response.data);
reset(unpackPark(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchPark();
}, [id]);
const handleFormSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const { data, error } = await updatePark(getValues(), id);
if (error) {
setError('Something went wrong');
return;
}
setPark(data);
reset(unpackPark(data));
} else {
const { data, error } = await createPark(getValues());
if (error) {
setError('Something went wrong');
return;
}
navigate(ROUTES.park.path.replace(':id', String(data.id)));
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackPark(park));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleFormSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('centerLatitude')}
render={(props) => (
<NumberField label={{ text: 'Center Latitude' }} {...props} />
)}
/>
<Controller
{...control('centerLongitude')}
render={(props) => (
<NumberField label={{ text: 'Center Longitude' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
<Controller
{...control('turbines')}
render={(props) => (
<ParkTurbines savedTurbines={park?.turbines ?? []} {...props} />
)}
/>
<Dialog
open={Boolean(error)}
heading="Error"
message="Something went wrong"
onClose={() => setError(null)}
>
<Button onClick={() => setError(null)}>Ok</Button>
</Dialog>
</div>
);
}

View File

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

View File

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

View File

@ -1,17 +0,0 @@
export type ParkFormTurbine = {
id: number;
name: string;
xOffset: string;
yOffset: string;
angle: string;
comment: string;
new?: boolean;
delete?: boolean;
};
export type ParkFormValues = {
name: string;
centerLatitude: string;
centerLongitude: string;
turbines: ParkFormTurbine[];
};

View File

@ -1,19 +0,0 @@
import { ParkWithTurbines } from '@api/wind';
import { ParkFormValues } from './types';
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
return {
name: park.name,
centerLatitude: String(park.centerLatitude),
centerLongitude: String(park.centerLongitude),
turbines: park.turbines.map((t) => ({
id: t.id,
name: t.name,
xOffset: String(t.xOffset),
yOffset: String(t.yOffset),
angle: String(t.angle),
comment: t.comment,
})),
};
};

View File

@ -1,64 +0,0 @@
import { deletePark, getParks, Park } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [selected, setSelected] = useState<Park>(null);
const route = useRoute();
const fetchParks = async () => {
const res = await getParks();
setParks(res.data);
};
useEffect(() => {
fetchParks();
}, []);
const handleParkSelect = (items: Park[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deletePark(selected.id);
fetchParks();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.park.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={parks}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleParkSelect}
multiselect={false}
/>
</div>
</div>
);
}

View File

@ -1,8 +0,0 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { Park } from 'src/api/wind';
export const columns: DataGridColumnConfig<Park>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
];

View File

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

View File

@ -1,19 +0,0 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

View File

@ -1,101 +0,0 @@
import {
createTurbineTypes,
editTurbineTypes,
getTurbineType,
TurbineType,
} from '@api/wind';
import { Button, Heading, NumberField, TextInput } from '@components/ui';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { TurbineTypeFormValues } from './types';
import { unpackTurbineType } from './utils';
export function TurbineTypePage() {
const [turbineType, setTurbineType] = useState<TurbineType>(null);
const [pending, setPending] = useState<boolean>(false);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } =
useForm<TurbineTypeFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchTurbineType = async () => {
const response = await getTurbineType(id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchTurbineType();
}, [id]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const response = await editTurbineTypes(getValues(), id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
} else {
const response = await createTurbineTypes(getValues());
navigate(
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
);
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackTurbineType(turbineType));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('height')}
render={(props) => (
<NumberField label={{ text: 'Height' }} {...props} />
)}
/>
<Controller
{...control('bladeLength')}
render={(props) => (
<NumberField label={{ text: 'Blade length' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
</div>
);
}

View File

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

View File

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

View File

@ -1,13 +0,0 @@
import { TurbineType } from '@api/wind';
import { TurbineTypeFormValues } from './types';
export const unpackTurbineType = (
turbineType: TurbineType,
): TurbineTypeFormValues => {
return {
name: turbineType.name,
height: String(turbineType.height),
bladeLength: String(turbineType.bladeLength),
};
};

View File

@ -1,66 +0,0 @@
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function TurbineTypesPage() {
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
const [selected, setSelected] = useState<TurbineType>(null);
const route = useRoute();
const fetchTurbineTypes = async () => {
const res = await getTurbineTypes();
setTurbineTypes(res.data ?? []);
};
useEffect(() => {
fetchTurbineTypes();
}, []);
const handleTurbineTypeSelect = (items: TurbineType[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deleteTurbineType(selected.id);
fetchTurbineTypes();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link
to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={turbineTypes}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleTurbineTypeSelect}
multiselect={false}
/>
</div>
</div>
);
}

View File

@ -1,8 +0,0 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { TurbineType } from 'src/api/wind';
export const columns: DataGridColumnConfig<TurbineType>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Height', getText: (t) => String(t.height) },
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
];

View File

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

View File

@ -1,19 +0,0 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,38 +5,3 @@
width: 200%;
height: 200%;
}
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
pointer-events: none;
}
.visible {
animation: fadein 0.25s linear;
}
.invisible {
animation: fadeout 0.25s linear;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

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

View File

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

View File

@ -1,119 +0,0 @@
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
import { useMissClick } from '@utils/miss-click';
import clsx from 'clsx';
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { Menu } from '../menu';
import { Popover } from '../popover';
import { TextInput } from '../text-input';
import styles from './styles.module.scss';
import { AutocompleteProps } from './types';
function AutocompleteInner<T>(
{
options,
value,
getOptionKey,
getOptionLabel,
onChange,
scale = 'm',
label = {},
name,
id,
}: Omit<AutocompleteProps<T>, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const autocompleteRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLUListElement | null>(null);
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
const [menuVisible, setMenuVisible] = useState<boolean>(false);
const [text, setText] = useState<string>('');
useImperativeHandle(ref, () => autocompleteRef.current, []);
useMissClick({
callback: () => setMenuVisible(false),
enabled: menuVisible,
whitelist: [autocompleteRef, menuRef],
});
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
[styles.menuVisible]: menuVisible,
});
const filteredOptions = options.filter((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = text.trim().toLocaleLowerCase();
return label.includes(raw);
});
const handleInputClick = () => {
setMenuVisible(!menuVisible);
};
const handleMenuSelect = (option: T) => {
setMenuVisible(false);
onChange?.(option);
setText('');
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setText(value);
const option = options.find((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = value.toLocaleLowerCase();
return label === raw;
});
onChange?.(option ?? null);
};
return (
<div className={autocompleteClassName} ref={autocompleteRef}>
<TextInput
value={value ? getOptionLabel(value) : text}
onClick={handleInputClick}
scale={scale}
label={label}
name={name}
id={id}
wrapper={{ ref: inputWrapperRef }}
onChange={handleInputChange}
rightNode={
<div className={styles.iconBox}>
<ArrowDownIcon className={styles.icon} />
</div>
}
/>
<Popover
visible={menuVisible}
anchorRef={autocompleteRef}
position="bottom"
horizontalAlign="stretch"
flip
element={
<div className={styles.menuWrapper}>
<Menu
options={filteredOptions}
selected={value}
getOptionKey={getOptionKey}
getOptionLabel={getOptionLabel}
onSelect={handleMenuSelect}
ref={menuRef}
/>
</div>
}
/>
</div>
);
}
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
props: AutocompleteProps<T>,
) => ReturnType<typeof AutocompleteInner>;

View File

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

View File

@ -1,44 +0,0 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { Autocomplete } from './component';
export function AutocompletePreview() {
const [selectValue, setSelectValue] = useState<string>();
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
return (
<PreviewArticle title="Autocomplete">
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="s"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="m"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="l"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
</PreviewArticle>
);
}

View File

@ -1,62 +0,0 @@
@use '@components/func.scss' as f;
.autocomplete {
position: relative;
width: fit-content;
}
.icon {
fill: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
}
.fade {
position: absolute;
z-index: 1;
}
.menuVisible {
.icon {
rotate: 180deg;
}
}
.menuWrapper {
padding: 5px 0;
}
$padding-right: 7px;
$size: 10px;
.s {
.iconBox {
padding-right: $padding-right;
}
.icon {
width: $size;
height: $size;
}
}
.m {
.iconBox {
padding-right: f.m($padding-right);
}
.icon {
width: f.m($size);
height: f.m($size);
}
}
.l {
.iconBox {
padding-right: f.l($padding-right);
}
.icon {
width: f.l($size);
height: f.l($size);
}
}

View File

@ -1,16 +0,0 @@
import { ComponentProps, Key } from 'react';
import { LabelProps } from '../label';
import { Scale } from '../types';
export type AutocompleteProps<T> = {
options: T[];
value?: T;
getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string;
onChange?: (option: T) => void;
scale?: Scale;
label?: LabelProps;
name?: string;
id?: string;
} & Omit<ComponentProps<'div'>, 'onChange'>;

View File

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

View File

@ -1,11 +1,14 @@
@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;
}
@ -23,8 +26,6 @@
}
.pending {
pointer-events: none;
.childrenWrapper {
visibility: hidden;
}
@ -34,42 +35,42 @@
background-color: var(--clr-primary);
color: var(--clr-on-primary);
@media (hover: hover) {
&:hover {
background-color: var(--clr-primary-hover);
}
&:hover {
background-color: var(--clr-primary-hover);
}
&.pending {
background-color: var(--clr-primary-active);
}
}
.secondary {
background-color: var(--clr-secondary);
color: var(--clr-on-secondary);
@media (hover: hover) {
&:hover {
background-color: var(--clr-secondary-hover);
}
&:hover {
background-color: var(--clr-secondary-hover);
}
&.pending {
background-color: var(--clr-secondary-active);
}
}
$padding: 10px 16px;
$border-radius: 8px;
$font-size: 12px;
.s {
padding: $padding;
border-radius: $border-radius;
font-size: $font-size;
padding: 10px 16px;
border-radius: 8px;
font-size: 12px;
}
.m {
padding: f.m($padding);
border-radius: f.m($border-radius);
font-size: f.m($font-size);
padding: 14px 20px;
border-radius: 10px;
font-size: 16px;
}
.l {
padding: f.l($padding);
border-radius: f.l($border-radius);
font-size: f.l($font-size);
padding: 18px 24px;
border-radius: 12px;
font-size: 20px;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,11 @@
import { dateToInputString } from '@utils/date';
import { CalendarDay, GetCalendarDaysParams } from './types';
const addDays = (date: Date, days: number) => {
date.setDate(date.getDate() + days);
};
function dateToInputString(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const daysAreEqual = (date1: Date, date2: Date) => {
return (
date1.getDate() === date2.getDate() &&

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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