Compare commits
18 Commits
prediction
...
main
Author | SHA1 | Date | |
---|---|---|---|
8adc9b1ea7 | |||
0aafeda77d | |||
93febb40d3 | |||
2d8465fb09 | |||
9ca0430fda | |||
658a351d28 | |||
a60304ca0f | |||
ee6bbdca8b | |||
507115ece6 | |||
4d40a2cacb | |||
6e87595e2f | |||
d387e5c5bf | |||
1fd0f946e6 | |||
|
484f1f205e | ||
|
65b90f9636 | ||
dee27b731b | |||
7cdbd65432 | |||
7db293de96 |
@ -1,10 +1,11 @@
|
|||||||
|
from PyWeather.weather.stations.davis import VantagePro
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
|
|
||||||
import mariadb
|
import mariadb
|
||||||
import serial.tools.list_ports
|
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",
|
logging.basicConfig(filename="Stations.log",
|
||||||
format='%(asctime)s %(message)s',
|
format='%(asctime)s %(message)s',
|
||||||
@ -12,10 +13,37 @@ logging.basicConfig(filename="Stations.log",
|
|||||||
logger = logging.getLogger('davis_api')
|
logger = logging.getLogger('davis_api')
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setLevel(logging.DEBUG)
|
def write_data(device, station, send=True):
|
||||||
console_handler.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
|
try:
|
||||||
logger.addHandler(console_handler)
|
#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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = mariadb.connect(
|
conn = mariadb.connect(
|
||||||
@ -29,25 +57,23 @@ try:
|
|||||||
except mariadb.Error as e:
|
except mariadb.Error as e:
|
||||||
logger.error('DB_ERR: ' + str(e))
|
logger.error('DB_ERR: ' + str(e))
|
||||||
raise e
|
raise e
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
ports = serial.tools.list_ports.comports()
|
|
||||||
available_ports = {}
|
|
||||||
|
|
||||||
for port in ports:
|
try:
|
||||||
if port.serial_number == '0001':
|
ports = serial.tools.list_ports.comports()
|
||||||
available_ports[port.name] = port.vid
|
available_ports = {}
|
||||||
|
|
||||||
devices = [VantagePro(port) for port in available_ports.keys()]
|
for port in ports:
|
||||||
while True:
|
if port.serial_number == '0001':
|
||||||
for i in range(1):
|
available_ports[port.name] = port.vid
|
||||||
if len(devices) != 0:
|
|
||||||
logger.info(devices)
|
devices = [VantagePro(port) for port in available_ports.keys()]
|
||||||
else:
|
print(available_ports)
|
||||||
raise Exception('Can`t connect to device')
|
while True:
|
||||||
time.sleep(60)
|
for i in range(len(devices)):
|
||||||
except Exception as e:
|
print(devices[i].fields)
|
||||||
logger.error('Device_error' + str(e))
|
#write_data(devices[i], 'st' + str(available_ports[list(available_ports.keys())[i]]), True)
|
||||||
time.sleep(60)
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Device_error: ' + str(e))
|
||||||
|
raise e
|
||||||
|
|
||||||
# todo переписать под influx, для линухи приколы сделать
|
|
||||||
|
@ -1,189 +0,0 @@
|
|||||||
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: tuple):
|
|
||||||
"""Создает объект 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]
|
|
||||||
wind_speeds=[]
|
|
||||||
wind_directions=[]
|
|
||||||
for i in range(u_values.numel()):
|
|
||||||
u_scalar = u_values.view(-1)[i].item() # Разворачиваем тензор в одномерный и берем элемент
|
|
||||||
v_scalar = v_values.view(-1)[i].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_speeds.append(wind_speed.magnitude.item())
|
|
||||||
wind_directions.append(wind_dir.magnitude.item())
|
|
||||||
|
|
||||||
return wind_speeds,wind_directions
|
|
||||||
|
|
||||||
|
|
||||||
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,)
|
|
||||||
|
|
||||||
date1 = datetime(2024, 11, 27, 12)
|
|
||||||
date2 = datetime(2024, 11, 28, 12)
|
|
||||||
date_tuple = (date1, date2,)
|
|
||||||
# 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_tuple)
|
|
||||||
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!")
|
|
6
front/package-lock.json
generated
6
front/package-lock.json
generated
@ -2811,7 +2811,7 @@
|
|||||||
"version": "15.7.13",
|
"version": "15.7.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
||||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.9.16",
|
"version": "6.9.16",
|
||||||
@ -2829,7 +2829,7 @@
|
|||||||
"version": "18.3.8",
|
"version": "18.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
|
||||||
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
|
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@ -4262,7 +4262,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
|
7
front/public/images/svg/arrow-left.svg
Normal file
7
front/public/images/svg/arrow-left.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
|
||||||
|
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
|
||||||
|
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 548 B |
7
front/public/images/svg/arrow-right.svg
Normal file
7
front/public/images/svg/arrow-right.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
|
||||||
|
<path d="M3.51,3.01c0,0.13-0.05,0.26-0.15,0.35l-2,2c-0.2,0.2-0.51,0.2-0.71,0c-0.2-0.2-0.2-0.51,0-0.71l1.64-1.64L0.64,1.36
|
||||||
|
c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l2,2C3.46,2.75,3.51,2.88,3.51,3.01z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 558 B |
8
front/public/images/svg/close.svg
Normal file
8
front/public/images/svg/close.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
|
||||||
|
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
|
||||||
|
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
|
||||||
|
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 646 B |
20
front/public/images/svg/menu.svg
Normal file
20
front/public/images/svg/menu.svg
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
|
||||||
|
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
|
||||||
|
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
|
||||||
|
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 914 B |
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
|
|
||||||
<path d="M9.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
|
|
||||||
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 541 B |
15
front/public/images/svg/upload.svg
Normal file
15
front/public/images/svg/upload.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
|
||||||
|
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
|
||||||
|
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
|
||||||
|
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
|
||||||
|
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
|
||||||
|
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
|
||||||
|
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
|
||||||
|
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
51
front/src/api/api.ts
Normal file
51
front/src/api/api.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { BASE_URL } from './constants';
|
||||||
|
import { ApiResponse } from './types';
|
||||||
|
import { unpack } from './utils';
|
||||||
|
|
||||||
|
const send = async <T>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
): Promise<ApiResponse<T>> => {
|
||||||
|
const fullURL = `${BASE_URL}/${url}`;
|
||||||
|
const fullInit: RequestInit = { ...init };
|
||||||
|
try {
|
||||||
|
const response = await fetch(fullURL, fullInit);
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: { status: response.status, message: 'Something went wrong' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const raw = await response.json();
|
||||||
|
const data: T = unpack(raw);
|
||||||
|
return { data: data, error: null };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: { status: 0, message: 'Something went wrong' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: async <T>(url: string) => {
|
||||||
|
return send<T>(url, { method: 'GET' });
|
||||||
|
},
|
||||||
|
post: async <T>(url: string, body: unknown) => {
|
||||||
|
return send<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
put: async <T>(url: string, body: unknown) => {
|
||||||
|
return send<T>(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delete: async <T>(url: string) => {
|
||||||
|
return send<T>(url, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
1
front/src/api/constants.ts
Normal file
1
front/src/api/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const BASE_URL = 'http://localhost:8000';
|
@ -1,2 +0,0 @@
|
|||||||
// export const BASE_URL = 'http://localhost:8000/api';
|
|
||||||
export const BASE_URL = 'http://192.168.1.110:8000/api';
|
|
@ -1 +0,0 @@
|
|||||||
export { downloadImage, getWindmillData } from './service';
|
|
@ -1,26 +0,0 @@
|
|||||||
import { WindmillFormStore } from '@components/ux/windmill-form';
|
|
||||||
|
|
||||||
import { BASE_URL } from './constants';
|
|
||||||
import { GetWindmillDataRes } from './types';
|
|
||||||
import { getWindmillDataParams } from './utils';
|
|
||||||
|
|
||||||
export const getWindmillData = async (store: Partial<WindmillFormStore>) => {
|
|
||||||
const params = getWindmillDataParams(store);
|
|
||||||
const url = `${BASE_URL}/floris/get_windmill_data?${params}`;
|
|
||||||
const init: RequestInit = {
|
|
||||||
method: 'GET',
|
|
||||||
};
|
|
||||||
const res: Response = await fetch(url, init);
|
|
||||||
const data: GetWindmillDataRes = await res.json();
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadImage = async (imageName: string) => {
|
|
||||||
const url = `${BASE_URL}/floris/download_image/${imageName}`;
|
|
||||||
const init: RequestInit = {
|
|
||||||
method: 'GET',
|
|
||||||
};
|
|
||||||
const res: Response = await fetch(url, init);
|
|
||||||
const data = await res.blob();
|
|
||||||
return data;
|
|
||||||
};
|
|
@ -1,4 +0,0 @@
|
|||||||
export type GetWindmillDataRes = {
|
|
||||||
file_name: string;
|
|
||||||
data: number[];
|
|
||||||
};
|
|
@ -1,9 +0,0 @@
|
|||||||
import { WindmillFormStore } from '@components/ux/windmill-form';
|
|
||||||
|
|
||||||
export const getWindmillDataParams = (store: Partial<WindmillFormStore>) => {
|
|
||||||
const layoutX = store.windmills?.map((row) => `layout_x=${row.x}`).join('&');
|
|
||||||
const layoutY = store.windmills?.map((row) => `layout_y=${row.y}`).join('&');
|
|
||||||
const dateStart = `date_start=${store.dateFrom?.substring(0, 10)}`;
|
|
||||||
const dateEnd = `date_end=${store.dateTo?.substring(0, 10)}`;
|
|
||||||
return `${layoutX}&${layoutY}&${dateStart}&${dateEnd}`;
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './floris';
|
|
9
front/src/api/types.ts
Normal file
9
front/src/api/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type ApiError = {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResponse<T> = {
|
||||||
|
data: T | null;
|
||||||
|
error: ApiError | null;
|
||||||
|
};
|
23
front/src/api/utils.ts
Normal file
23
front/src/api/utils.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export const toCamelCase = (str: string) => {
|
||||||
|
return str
|
||||||
|
.split(/[_\s-]+|(?=[A-Z])/)
|
||||||
|
.map((word, index) =>
|
||||||
|
index === 0
|
||||||
|
? word.toLowerCase()
|
||||||
|
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unpack = (obj: unknown) => {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => unpack(item));
|
||||||
|
} else if (obj !== null && typeof obj === 'object') {
|
||||||
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
const newKey = toCamelCase(key);
|
||||||
|
acc[newKey] = unpack(value);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
7
front/src/api/wind/constants.ts
Normal file
7
front/src/api/wind/constants.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const WIND_ENDPOINTS = {
|
||||||
|
turbines: 'api/wind/turbines',
|
||||||
|
turbineType: 'api/wind/turbine_type',
|
||||||
|
parks: 'api/wind/parks',
|
||||||
|
park: 'api/wind/park',
|
||||||
|
parkTurbine: 'api/wind/park_turbine',
|
||||||
|
};
|
2
front/src/api/wind/index.ts
Normal file
2
front/src/api/wind/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './service';
|
||||||
|
export * from './types';
|
117
front/src/api/wind/service.ts
Normal file
117
front/src/api/wind/service.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { ApiResponse } from '@api/types';
|
||||||
|
import { ParkFormValues } from '@components/pages/park-page/types';
|
||||||
|
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
|
import { WIND_ENDPOINTS } from './constants';
|
||||||
|
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
|
||||||
|
import { packPark, packParkTurbine, packTurbineTypes } from './utils';
|
||||||
|
|
||||||
|
export const getTurbineTypes = () => {
|
||||||
|
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTurbineType = (id: string) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||||
|
return api.get<TurbineType>(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTurbineTypes = (
|
||||||
|
formValues: Partial<TurbineTypeFormValues>,
|
||||||
|
) => {
|
||||||
|
return api.post<TurbineType>(
|
||||||
|
WIND_ENDPOINTS.turbineType,
|
||||||
|
packTurbineTypes(formValues),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editTurbineTypes = (
|
||||||
|
formValues: Partial<TurbineTypeFormValues>,
|
||||||
|
id: string,
|
||||||
|
) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||||
|
return api.put<TurbineType>(url, packTurbineTypes(formValues));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTurbineType = (id: number) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||||
|
return api.delete(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getParks = () => {
|
||||||
|
return api.get<Park[]>(WIND_ENDPOINTS.parks);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPark = (id: string) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||||
|
return api.get<Park>(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getParkTurbines = (id: string) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
|
||||||
|
return api.get<ParkTurbine[]>(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getParkWithTurbines = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<ApiResponse<ParkWithTurbines>> => {
|
||||||
|
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
|
||||||
|
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
|
||||||
|
const parkPesponse = await api.get<Park>(parkURL);
|
||||||
|
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
|
||||||
|
return {
|
||||||
|
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
|
||||||
|
error: parkPesponse.error || turbinesResponse.error || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPark = async (formValues: Partial<ParkFormValues>) => {
|
||||||
|
const parkPesponse = await api.post<Park>(
|
||||||
|
WIND_ENDPOINTS.park,
|
||||||
|
packPark(formValues),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
formValues.turbines?.map((t) => {
|
||||||
|
return api.post(
|
||||||
|
WIND_ENDPOINTS.parkTurbine,
|
||||||
|
packParkTurbine(t, parkPesponse.data.id),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return getParkWithTurbines(String(parkPesponse.data.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePark = async (
|
||||||
|
formValues: Partial<ParkFormValues>,
|
||||||
|
id: string,
|
||||||
|
) => {
|
||||||
|
const parkPesponse = await api.put<Park>(
|
||||||
|
`${WIND_ENDPOINTS.park}/${id}`,
|
||||||
|
packPark(formValues),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
formValues.turbines?.map((t) => {
|
||||||
|
if (t.new) {
|
||||||
|
return api.post(
|
||||||
|
WIND_ENDPOINTS.parkTurbine,
|
||||||
|
packParkTurbine(t, parkPesponse.data.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (t.delete) {
|
||||||
|
return api.delete(
|
||||||
|
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return api.put(
|
||||||
|
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
|
||||||
|
packParkTurbine(t, parkPesponse.data.id),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return getParkWithTurbines(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePark = (id: number) => {
|
||||||
|
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||||
|
return api.delete(url);
|
||||||
|
};
|
32
front/src/api/wind/types.ts
Normal file
32
front/src/api/wind/types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type TurbineType = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
height: number;
|
||||||
|
bladeLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Park = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
centerLatitude: number;
|
||||||
|
centerLongitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParkTurbine = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
height: number;
|
||||||
|
bladeLength: number;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
angle: number;
|
||||||
|
comment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParkWithTurbines = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
centerLatitude: number;
|
||||||
|
centerLongitude: number;
|
||||||
|
turbines: ParkTurbine[];
|
||||||
|
};
|
32
front/src/api/wind/utils.ts
Normal file
32
front/src/api/wind/utils.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
ParkFormTurbine,
|
||||||
|
ParkFormValues,
|
||||||
|
} from '@components/pages/park-page/types';
|
||||||
|
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
|
||||||
|
|
||||||
|
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
|
||||||
|
return {
|
||||||
|
Name: values.name ?? '',
|
||||||
|
Height: parseInt(values.height || '0'),
|
||||||
|
BladeLength: parseInt(values.bladeLength || '0'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const packPark = (values: Partial<ParkFormValues>) => {
|
||||||
|
return {
|
||||||
|
Name: values.name ?? '',
|
||||||
|
CenterLatitude: parseInt(values.centerLatitude || '0'),
|
||||||
|
CenterLongitude: parseInt(values.centerLongitude || '0'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const packParkTurbine = (turbine: ParkFormTurbine, parkId: number) => {
|
||||||
|
return {
|
||||||
|
wind_park_id: parkId,
|
||||||
|
turbine_id: turbine.id,
|
||||||
|
x_offset: parseInt(turbine.xOffset || '0'),
|
||||||
|
y_offset: parseInt(turbine.yOffset || '0'),
|
||||||
|
angle: parseInt(turbine.angle || '0'),
|
||||||
|
comment: turbine.comment ?? '',
|
||||||
|
};
|
||||||
|
};
|
19
front/src/components/_func.scss
Normal file
19
front/src/components/_func.scss
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
@function scale($values, $factor) {
|
||||||
|
@if type-of($values) == 'list' {
|
||||||
|
$m-values: ();
|
||||||
|
@each $value in $values {
|
||||||
|
$m-values: append($m-values, $value * $factor);
|
||||||
|
}
|
||||||
|
@return $m-values;
|
||||||
|
} @else {
|
||||||
|
@return nth($values, 1) * $factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@function m($values) {
|
||||||
|
@return scale($values, 1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@function l($values) {
|
||||||
|
@return scale($values, 1.5);
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
@mixin on-mobile {
|
||||||
|
@media (width <= 800px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,12 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
--clr-primary: #4176FF;
|
--clr-primary: #4176FF;
|
||||||
--clr-primary-o50: #3865DA80;
|
--clr-primary-o50: #4176FF80;
|
||||||
--clr-primary-hover: #638FFF;
|
--clr-primary-hover: #638FFF;
|
||||||
--clr-primary-active: #3D68D7;
|
|
||||||
--clr-on-primary: #FFFFFF;
|
--clr-on-primary: #FFFFFF;
|
||||||
|
|
||||||
--clr-secondary: #EAEAEA;
|
--clr-secondary: #E1EAF8;
|
||||||
--clr-secondary-hover: #EFEFEF;
|
--clr-secondary-hover: #E8ECF0;
|
||||||
--clr-secondary-active: #E1E1E1;
|
|
||||||
--clr-on-secondary: #0D0D0D;
|
--clr-on-secondary: #0D0D0D;
|
||||||
|
|
||||||
--clr-layer-100: #EBEEF0;
|
--clr-layer-100: #EBEEF0;
|
||||||
@ -20,6 +18,7 @@
|
|||||||
--clr-text-100: #8D8D8D;
|
--clr-text-100: #8D8D8D;
|
||||||
--clr-text-200: #6C7480;
|
--clr-text-200: #6C7480;
|
||||||
--clr-text-300: #1D1F20;
|
--clr-text-300: #1D1F20;
|
||||||
|
--clr-text-primary: #3865DA;
|
||||||
|
|
||||||
--clr-border-100: #DFDFDF;
|
--clr-border-100: #DFDFDF;
|
||||||
--clr-border-200: #D8D8D8;
|
--clr-border-200: #D8D8D8;
|
||||||
@ -28,6 +27,8 @@
|
|||||||
--clr-shadow-200: #00000026;
|
--clr-shadow-200: #00000026;
|
||||||
|
|
||||||
--clr-ripple: #1D1F2026;
|
--clr-ripple: #1D1F2026;
|
||||||
|
|
||||||
|
--clr-error: #E54B4B;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
@ -36,12 +37,12 @@
|
|||||||
--clr-primary: #3865DA;
|
--clr-primary: #3865DA;
|
||||||
--clr-primary-o50: #3865DA80;
|
--clr-primary-o50: #3865DA80;
|
||||||
--clr-primary-hover: #4073F7;
|
--clr-primary-hover: #4073F7;
|
||||||
--clr-primary-active: #2A4DA7;
|
--clr-primary-disabled: #334570;
|
||||||
--clr-on-primary: #FFFFFF;
|
--clr-on-primary: #FFFFFF;
|
||||||
|
|
||||||
--clr-secondary: #3F3F3F;
|
--clr-secondary: #3F3F3F;
|
||||||
--clr-secondary-hover: #4D4D4D;
|
--clr-secondary-hover: #4D4D4D;
|
||||||
--clr-secondary-active: #323232;
|
--clr-secondary-disabled: #323232;
|
||||||
--clr-on-secondary: #FFFFFF;
|
--clr-on-secondary: #FFFFFF;
|
||||||
|
|
||||||
--clr-layer-100: #1B1B1B;
|
--clr-layer-100: #1B1B1B;
|
||||||
@ -52,6 +53,7 @@
|
|||||||
--clr-text-100: #888888;
|
--clr-text-100: #888888;
|
||||||
--clr-text-200: #C5C5C5;
|
--clr-text-200: #C5C5C5;
|
||||||
--clr-text-300: #F0F0F0;
|
--clr-text-300: #F0F0F0;
|
||||||
|
--clr-text-primary: #4176FF;
|
||||||
|
|
||||||
--clr-border-100: #3D3D3D;
|
--clr-border-100: #3D3D3D;
|
||||||
--clr-border-200: #545454;
|
--clr-border-200: #545454;
|
||||||
@ -60,4 +62,6 @@
|
|||||||
--clr-shadow-200: #00000026;
|
--clr-shadow-200: #00000026;
|
||||||
|
|
||||||
--clr-ripple: #F0F0F026;
|
--clr-ripple: #F0F0F026;
|
||||||
|
|
||||||
|
--clr-error: #FF6363;
|
||||||
}
|
}
|
||||||
|
35
front/src/components/app/component.tsx
Normal file
35
front/src/components/app/component.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import './styles.scss';
|
||||||
|
import '@public/fonts/styles.css';
|
||||||
|
|
||||||
|
import { MainLayout } from '@components/layouts';
|
||||||
|
import {
|
||||||
|
ParkPage,
|
||||||
|
ParksPage,
|
||||||
|
TurbineTypePage,
|
||||||
|
TurbineTypesPage,
|
||||||
|
} from '@components/pages';
|
||||||
|
import { ROUTES } from '@utils/route';
|
||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.turbineTypes.path}
|
||||||
|
element={<TurbineTypesPage />}
|
||||||
|
/>
|
||||||
|
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
|
||||||
|
<Route path={ROUTES.parks.path} element={<ParksPage />} />
|
||||||
|
<Route path={ROUTES.park.path} element={<ParkPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/app/index.ts
Normal file
1
front/src/components/app/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { App } from './component';
|
@ -1,21 +0,0 @@
|
|||||||
import './styles.scss';
|
|
||||||
import '@public/fonts/styles.css';
|
|
||||||
|
|
||||||
import { MainLayout } from '@components/layouts';
|
|
||||||
import { HomePage } from '@components/pages';
|
|
||||||
import React from 'react';
|
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route element={<MainLayout />}>
|
|
||||||
<Route path={'/'} element={<HomePage />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -23,7 +23,7 @@ html[data-theme='default'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
--td-100: 0.2s;
|
--td-100: 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
1
front/src/components/layouts/index.ts
Normal file
1
front/src/components/layouts/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MainLayout } from './main-layout';
|
@ -1,3 +0,0 @@
|
|||||||
import MainLayout from './main-layout';
|
|
||||||
|
|
||||||
export { MainLayout };
|
|
22
front/src/components/layouts/main-layout/component.tsx
Normal file
22
front/src/components/layouts/main-layout/component.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Header, Sidebar } from '@components/ux';
|
||||||
|
import { useDeviceType } from '@utils/device';
|
||||||
|
import React from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const deviceType = useDeviceType();
|
||||||
|
return (
|
||||||
|
<div className={styles.mainLayout}>
|
||||||
|
{deviceType === 'mobile' ? (
|
||||||
|
<Header className={styles.header} />
|
||||||
|
) : (
|
||||||
|
<Sidebar className={styles.sidebar} />
|
||||||
|
)}
|
||||||
|
<main className={styles.main}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,18 +1 @@
|
|||||||
import { Header } from '@components/ux';
|
export * from './component';
|
||||||
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;
|
|
||||||
|
@ -1,13 +1,38 @@
|
|||||||
|
@use '@components/mixins.scss' as m;
|
||||||
|
|
||||||
.mainLayout {
|
.mainLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
grid-template:
|
grid-template:
|
||||||
'header' auto
|
'sidebar main' minmax(0, 1fr)
|
||||||
'main' minmax(0, 1fr)
|
/ auto minmax(0, 1fr);
|
||||||
/ minmax(0, 1fr);
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
grid-area: sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
grid-area: header;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
display: grid;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
grid-area: main;
|
||||||
|
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include m.on-mobile {
|
||||||
|
.mainLayout {
|
||||||
|
grid-template:
|
||||||
|
'header' auto
|
||||||
|
'main' minmax(0, 1fr)
|
||||||
|
/ minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import { Heading, Paragraph } from '@components/ui';
|
|
||||||
import { WindmillForm } from '@components/ux';
|
|
||||||
import { WindmillFormResponse } from '@components/ux/windmill-form';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
|
||||||
|
|
||||||
export function HomePage() {
|
|
||||||
const [result, setResult] = useState<WindmillFormResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleFormSuccess = (response: WindmillFormResponse) => {
|
|
||||||
setResult(response);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormFail = (message: string) => {
|
|
||||||
setError(message);
|
|
||||||
setResult(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.page}>
|
|
||||||
<div className={styles.wrapperForm}>
|
|
||||||
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.result}>
|
|
||||||
<Heading tag="h3">Result</Heading>
|
|
||||||
{result && (
|
|
||||||
<>
|
|
||||||
<div className={styles.power}>{result.power.join(' ')}</div>
|
|
||||||
<div className={styles.image}>
|
|
||||||
{result.image && <img src={result.image} alt="Image" />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error && <Paragraph>{error}</Paragraph>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { HomePage } from './component';
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
4
front/src/components/pages/index.ts
Normal file
4
front/src/components/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './park-page';
|
||||||
|
export * from './parks-page';
|
||||||
|
export * from './turbine-type-page';
|
||||||
|
export * from './turbine-types-page';
|
@ -1 +0,0 @@
|
|||||||
export { HomePage } from './home-page';
|
|
128
front/src/components/pages/park-page/component.tsx
Normal file
128
front/src/components/pages/park-page/component.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
createPark,
|
||||||
|
getParkWithTurbines,
|
||||||
|
ParkWithTurbines,
|
||||||
|
updatePark,
|
||||||
|
} from '@api/wind';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Heading,
|
||||||
|
NumberField,
|
||||||
|
TextInput,
|
||||||
|
} from '@components/ui';
|
||||||
|
import { ParkTurbines } from '@components/ux';
|
||||||
|
import { Controller, useForm } from '@utils/form';
|
||||||
|
import { ROUTES, useRoute } from '@utils/route';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { ParkFormValues } from './types';
|
||||||
|
import { unpackPark } from './utils';
|
||||||
|
|
||||||
|
export function ParkPage() {
|
||||||
|
const [park, setPark] = useState<ParkWithTurbines>(null);
|
||||||
|
const [pending, setPending] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string>(null);
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
const isEdit = id !== 'new';
|
||||||
|
const heading = isEdit ? 'Edit' : 'Create new';
|
||||||
|
|
||||||
|
const fetchPark = async () => {
|
||||||
|
const response = await getParkWithTurbines(id);
|
||||||
|
setPark(response.data);
|
||||||
|
reset(unpackPark(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchPark();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
if (isEdit) {
|
||||||
|
const { data, error } = await updatePark(getValues(), id);
|
||||||
|
if (error) {
|
||||||
|
setError('Something went wrong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPark(data);
|
||||||
|
reset(unpackPark(data));
|
||||||
|
} else {
|
||||||
|
const { data, error } = await createPark(getValues());
|
||||||
|
if (error) {
|
||||||
|
setError('Something went wrong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(ROUTES.park.path.replace(':id', String(data.id)));
|
||||||
|
}
|
||||||
|
setPending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (isEdit) {
|
||||||
|
reset(unpackPark(park));
|
||||||
|
} else {
|
||||||
|
reset({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} onSubmit={handleFormSubmit}>
|
||||||
|
<Heading tag="h1">{route.title}</Heading>
|
||||||
|
<form className={styles.form}>
|
||||||
|
<header>
|
||||||
|
<Heading tag="h3">{heading}</Heading>
|
||||||
|
</header>
|
||||||
|
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||||
|
<div className={styles.inputBox}>
|
||||||
|
<Controller
|
||||||
|
{...control('centerLatitude')}
|
||||||
|
render={(props) => (
|
||||||
|
<NumberField label={{ text: 'Center Latitude' }} {...props} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
{...control('centerLongitude')}
|
||||||
|
render={(props) => (
|
||||||
|
<NumberField label={{ text: 'Center Longitude' }} {...props} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttonBox}>
|
||||||
|
<Button variant="secondary" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" pending={pending}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<Controller
|
||||||
|
{...control('turbines')}
|
||||||
|
render={(props) => (
|
||||||
|
<ParkTurbines savedTurbines={park?.turbines ?? []} {...props} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(error)}
|
||||||
|
heading="Error"
|
||||||
|
message="Something went wrong"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
>
|
||||||
|
<Button onClick={() => setError(null)}>Ok</Button>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,23 +1,28 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
padding: 40px 20px;
|
||||||
|
gap: 20px;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 10px;
|
border-radius: 15px;
|
||||||
background-color: var(--clr-layer-200);
|
background-color: var(--clr-layer-200);
|
||||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
& > * {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dateRangeBox {
|
.inputBox {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonBox {
|
.buttonBox {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
padding-top: 20px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
17
front/src/components/pages/park-page/types.ts
Normal file
17
front/src/components/pages/park-page/types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export type ParkFormTurbine = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
xOffset: string;
|
||||||
|
yOffset: string;
|
||||||
|
angle: string;
|
||||||
|
comment: string;
|
||||||
|
new?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParkFormValues = {
|
||||||
|
name: string;
|
||||||
|
centerLatitude: string;
|
||||||
|
centerLongitude: string;
|
||||||
|
turbines: ParkFormTurbine[];
|
||||||
|
};
|
19
front/src/components/pages/park-page/utils.ts
Normal file
19
front/src/components/pages/park-page/utils.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ParkWithTurbines } from '@api/wind';
|
||||||
|
|
||||||
|
import { ParkFormValues } from './types';
|
||||||
|
|
||||||
|
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
|
||||||
|
return {
|
||||||
|
name: park.name,
|
||||||
|
centerLatitude: String(park.centerLatitude),
|
||||||
|
centerLongitude: String(park.centerLongitude),
|
||||||
|
turbines: park.turbines.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
xOffset: String(t.xOffset),
|
||||||
|
yOffset: String(t.yOffset),
|
||||||
|
angle: String(t.angle),
|
||||||
|
comment: t.comment,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
64
front/src/components/pages/parks-page/component.tsx
Normal file
64
front/src/components/pages/parks-page/component.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { deletePark, getParks, Park } from '@api/wind';
|
||||||
|
import { Button, Heading } from '@components/ui';
|
||||||
|
import { DataGrid } from '@components/ui/data-grid';
|
||||||
|
import { ROUTES, useRoute } from '@utils/route';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { columns } from './constants';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
export function ParksPage() {
|
||||||
|
const [parks, setParks] = useState<Park[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Park>(null);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const fetchParks = async () => {
|
||||||
|
const res = await getParks();
|
||||||
|
setParks(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchParks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleParkSelect = (items: Park[]) => {
|
||||||
|
setSelected(items[0] ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteButtonClick = async () => {
|
||||||
|
await deletePark(selected.id);
|
||||||
|
fetchParks();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Heading tag="h1">{route.title}</Heading>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Link to={ROUTES.park.path.replace(':id', 'new')}>
|
||||||
|
<Button>Create new</Button>
|
||||||
|
</Link>
|
||||||
|
{selected && (
|
||||||
|
<Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
|
||||||
|
<Button variant="secondary">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{selected && (
|
||||||
|
<Button variant="secondary" onClick={handleDeleteButtonClick}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataGridWrapper}>
|
||||||
|
<DataGrid
|
||||||
|
items={parks}
|
||||||
|
columns={columns}
|
||||||
|
getItemKey={({ id }) => String(id)}
|
||||||
|
selectedItems={selected ? [selected] : []}
|
||||||
|
onItemsSelect={handleParkSelect}
|
||||||
|
multiselect={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
8
front/src/components/pages/parks-page/constants.ts
Normal file
8
front/src/components/pages/parks-page/constants.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||||
|
import { Park } from 'src/api/wind';
|
||||||
|
|
||||||
|
export const columns: DataGridColumnConfig<Park>[] = [
|
||||||
|
{ name: 'Name', getText: (t) => t.name, width: 2 },
|
||||||
|
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
|
||||||
|
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
|
||||||
|
];
|
19
front/src/components/pages/parks-page/styles.module.scss
Normal file
19
front/src/components/pages/parks-page/styles.module.scss
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
padding: 40px 20px;
|
||||||
|
gap: 20px;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataGridWrapper {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background-color: var(--clr-layer-200);
|
||||||
|
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
101
front/src/components/pages/turbine-type-page/component.tsx
Normal file
101
front/src/components/pages/turbine-type-page/component.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
createTurbineTypes,
|
||||||
|
editTurbineTypes,
|
||||||
|
getTurbineType,
|
||||||
|
TurbineType,
|
||||||
|
} from '@api/wind';
|
||||||
|
import { Button, Heading, NumberField, TextInput } from '@components/ui';
|
||||||
|
import { Controller, useForm } from '@utils/form';
|
||||||
|
import { ROUTES, useRoute } from '@utils/route';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { TurbineTypeFormValues } from './types';
|
||||||
|
import { unpackTurbineType } from './utils';
|
||||||
|
|
||||||
|
export function TurbineTypePage() {
|
||||||
|
const [turbineType, setTurbineType] = useState<TurbineType>(null);
|
||||||
|
const [pending, setPending] = useState<boolean>(false);
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { register, control, getValues, reset } =
|
||||||
|
useForm<TurbineTypeFormValues>({});
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
const isEdit = id !== 'new';
|
||||||
|
const heading = isEdit ? 'Edit' : 'Create new';
|
||||||
|
|
||||||
|
const fetchTurbineType = async () => {
|
||||||
|
const response = await getTurbineType(id);
|
||||||
|
setTurbineType(response.data);
|
||||||
|
reset(unpackTurbineType(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchTurbineType();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
if (isEdit) {
|
||||||
|
const response = await editTurbineTypes(getValues(), id);
|
||||||
|
setTurbineType(response.data);
|
||||||
|
reset(unpackTurbineType(response.data));
|
||||||
|
} else {
|
||||||
|
const response = await createTurbineTypes(getValues());
|
||||||
|
navigate(
|
||||||
|
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (isEdit) {
|
||||||
|
reset(unpackTurbineType(turbineType));
|
||||||
|
} else {
|
||||||
|
reset({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} onSubmit={handleSubmit}>
|
||||||
|
<Heading tag="h1">{route.title}</Heading>
|
||||||
|
<form className={styles.form}>
|
||||||
|
<header>
|
||||||
|
<Heading tag="h3">{heading}</Heading>
|
||||||
|
</header>
|
||||||
|
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||||
|
<div className={styles.inputBox}>
|
||||||
|
<Controller
|
||||||
|
{...control('height')}
|
||||||
|
render={(props) => (
|
||||||
|
<NumberField label={{ text: 'Height' }} {...props} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
{...control('bladeLength')}
|
||||||
|
render={(props) => (
|
||||||
|
<NumberField label={{ text: 'Blade length' }} {...props} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttonBox}>
|
||||||
|
<Button variant="secondary" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" pending={pending}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
front/src/components/pages/turbine-type-page/index.ts
Normal file
1
front/src/components/pages/turbine-type-page/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './component';
|
@ -0,0 +1,28 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
padding: 40px 20px;
|
||||||
|
gap: 20px;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background-color: var(--clr-layer-200);
|
||||||
|
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputBox {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonBox {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
padding-top: 20px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
5
front/src/components/pages/turbine-type-page/types.ts
Normal file
5
front/src/components/pages/turbine-type-page/types.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type TurbineTypeFormValues = {
|
||||||
|
name: string;
|
||||||
|
height: string;
|
||||||
|
bladeLength: string;
|
||||||
|
};
|
13
front/src/components/pages/turbine-type-page/utils.ts
Normal file
13
front/src/components/pages/turbine-type-page/utils.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { TurbineType } from '@api/wind';
|
||||||
|
|
||||||
|
import { TurbineTypeFormValues } from './types';
|
||||||
|
|
||||||
|
export const unpackTurbineType = (
|
||||||
|
turbineType: TurbineType,
|
||||||
|
): TurbineTypeFormValues => {
|
||||||
|
return {
|
||||||
|
name: turbineType.name,
|
||||||
|
height: String(turbineType.height),
|
||||||
|
bladeLength: String(turbineType.bladeLength),
|
||||||
|
};
|
||||||
|
};
|
66
front/src/components/pages/turbine-types-page/component.tsx
Normal file
66
front/src/components/pages/turbine-types-page/component.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
|
||||||
|
import { Button, Heading } from '@components/ui';
|
||||||
|
import { DataGrid } from '@components/ui/data-grid';
|
||||||
|
import { ROUTES, useRoute } from '@utils/route';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { columns } from './constants';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
export function TurbineTypesPage() {
|
||||||
|
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
|
||||||
|
const [selected, setSelected] = useState<TurbineType>(null);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const fetchTurbineTypes = async () => {
|
||||||
|
const res = await getTurbineTypes();
|
||||||
|
setTurbineTypes(res.data ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTurbineTypes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTurbineTypeSelect = (items: TurbineType[]) => {
|
||||||
|
setSelected(items[0] ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteButtonClick = async () => {
|
||||||
|
await deleteTurbineType(selected.id);
|
||||||
|
fetchTurbineTypes();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Heading tag="h1">{route.title}</Heading>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
|
||||||
|
<Button>Create new</Button>
|
||||||
|
</Link>
|
||||||
|
{selected && (
|
||||||
|
<Link
|
||||||
|
to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
|
||||||
|
>
|
||||||
|
<Button variant="secondary">Edit</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{selected && (
|
||||||
|
<Button variant="secondary" onClick={handleDeleteButtonClick}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataGridWrapper}>
|
||||||
|
<DataGrid
|
||||||
|
items={turbineTypes}
|
||||||
|
columns={columns}
|
||||||
|
getItemKey={({ id }) => String(id)}
|
||||||
|
selectedItems={selected ? [selected] : []}
|
||||||
|
onItemsSelect={handleTurbineTypeSelect}
|
||||||
|
multiselect={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||||
|
import { TurbineType } from 'src/api/wind';
|
||||||
|
|
||||||
|
export const columns: DataGridColumnConfig<TurbineType>[] = [
|
||||||
|
{ name: 'Name', getText: (t) => t.name, width: 2 },
|
||||||
|
{ name: 'Height', getText: (t) => String(t.height) },
|
||||||
|
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
|
||||||
|
];
|
1
front/src/components/pages/turbine-types-page/index.ts
Normal file
1
front/src/components/pages/turbine-types-page/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './component';
|
@ -0,0 +1,19 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
padding: 40px 20px;
|
||||||
|
gap: 20px;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataGridWrapper {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background-color: var(--clr-layer-200);
|
||||||
|
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
@ -1,5 +1,11 @@
|
|||||||
import clsx from 'clsx';
|
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 styles from './styles.module.scss';
|
||||||
import { FadeProps } from './types';
|
import { FadeProps } from './types';
|
||||||
@ -14,7 +20,7 @@ export function FadeInner(
|
|||||||
}: Omit<FadeProps, 'ref'>,
|
}: Omit<FadeProps, 'ref'>,
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
|
const [visibleInternal, setVisibleInternal] = useState<boolean>(visible);
|
||||||
|
|
||||||
const classNames = clsx(
|
const classNames = clsx(
|
||||||
styles.fade,
|
styles.fade,
|
||||||
@ -25,22 +31,21 @@ export function FadeInner(
|
|||||||
const inlineStyle = {
|
const inlineStyle = {
|
||||||
...style,
|
...style,
|
||||||
'--animation-duration': `${duration}ms`,
|
'--animation-duration': `${duration}ms`,
|
||||||
} as React.CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setVisibleInner(true);
|
setVisibleInternal(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
||||||
if (event.animationName === styles.fadeout) {
|
if (event.animationName === styles.fadeout) {
|
||||||
setVisibleInner(false);
|
setVisibleInternal(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!visibleInner) {
|
if (!visibleInternal) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
.fade {
|
.fade {
|
||||||
animation: fadein var(--animation-duration);
|
animation: fadein var(--animation-duration) ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invisible {
|
.invisible {
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
export type FadeProps = {
|
export type FadeProps = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
} & React.ComponentProps<'div'>;
|
} & ComponentProps<'div'>;
|
||||||
|
@ -1,68 +1,65 @@
|
|||||||
import React, {
|
import clsx from 'clsx';
|
||||||
ForwardedRef,
|
import React, { useRef } from 'react';
|
||||||
forwardRef,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { RippleWave } from './parts/ripple-wave';
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { RippleProps } from './types';
|
|
||||||
import { calcRippleWaveStyle } from './utils';
|
import { calcRippleWaveStyle } from './utils';
|
||||||
|
|
||||||
export function RippleInner(
|
export function Ripple() {
|
||||||
props: RippleProps,
|
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const rippleRef = useRef<HTMLDivElement | null>(null);
|
const rippleRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
|
|
||||||
const [isTouch, setIsTouch] = useState(false);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => rippleRef.current, []);
|
const clean = () => {
|
||||||
|
document.removeEventListener('touchend', clean);
|
||||||
|
document.removeEventListener('mouseup', clean);
|
||||||
|
if (!rippleRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { lastChild: wave } = rippleRef.current;
|
||||||
|
if (!wave || !(wave instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wave.dataset.isMouseReleased = 'true';
|
||||||
|
if (wave.dataset.isAnimationComplete) {
|
||||||
|
wave.classList.replace(styles.visible, styles.invisible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleWaveOnDone = () => {
|
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||||
setWaves((prev) => prev.slice(1));
|
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 addWave = (x: number, y: number) => {
|
||||||
|
const wave = document.createElement('div');
|
||||||
const style = calcRippleWaveStyle(x, y, rippleRef.current);
|
const style = calcRippleWaveStyle(x, y, rippleRef.current);
|
||||||
const wave = (
|
Object.assign(wave.style, style);
|
||||||
<RippleWave
|
wave.className = clsx(styles.wave, styles.visible);
|
||||||
key={new Date().getTime()}
|
wave.addEventListener('animationend', handleAnimationEnd);
|
||||||
style={style}
|
rippleRef.current.appendChild(wave);
|
||||||
onDone={handleWaveOnDone}
|
document.addEventListener('touchend', clean);
|
||||||
/>
|
document.addEventListener('mouseup', clean);
|
||||||
);
|
|
||||||
setWaves([...waves, wave]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: React.MouseEvent) => {
|
const handlePointerDown = (event: React.MouseEvent) => {
|
||||||
if (isTouch) {
|
event.stopPropagation();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { pageX, pageY } = event;
|
const { pageX, pageY } = event;
|
||||||
addWave(pageX, pageY);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rippleRef}
|
|
||||||
className={styles.ripple}
|
className={styles.ripple}
|
||||||
onMouseDown={handleMouseDown}
|
ref={rippleRef}
|
||||||
onTouchStart={handleTouchStart}
|
onPointerDown={handlePointerDown}
|
||||||
{...props}
|
/>
|
||||||
>
|
|
||||||
{waves}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ripple = forwardRef(RippleInner);
|
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import styles from './style.module.scss';
|
|
||||||
import { RippleWaveProps } from './types';
|
|
||||||
|
|
||||||
export function RippleWave({ style, onDone }: RippleWaveProps) {
|
|
||||||
const [isMouseUp, setIsMouseUp] = useState(false);
|
|
||||||
const [isAnimationEnd, setIsAnimationEnd] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mouseUpListener = () => setIsMouseUp(true);
|
|
||||||
document.addEventListener('mouseup', mouseUpListener, { once: true });
|
|
||||||
document.addEventListener('touchend', mouseUpListener, { once: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const visible = !isMouseUp || !isAnimationEnd;
|
|
||||||
|
|
||||||
const className = clsx(
|
|
||||||
styles.wave,
|
|
||||||
visible ? styles.visible : styles.invisible,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
|
||||||
if (event.animationName === styles.fadein) {
|
|
||||||
setIsAnimationEnd(true);
|
|
||||||
} else {
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
onAnimationEnd={handleAnimationEnd}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { RippleWave } from './component';
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
export type RippleWaveProps = {
|
|
||||||
style: CSSProperties;
|
|
||||||
onDone: () => void;
|
|
||||||
};
|
|
@ -5,3 +5,38 @@
|
|||||||
width: 200%;
|
width: 200%;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export type RippleProps = {} & React.ComponentProps<'div'>;
|
|
@ -1,3 +1,4 @@
|
|||||||
|
import { px } from '@utils/css';
|
||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
export const calcRippleWaveStyle = (
|
export const calcRippleWaveStyle = (
|
||||||
@ -8,7 +9,7 @@ export const calcRippleWaveStyle = (
|
|||||||
const wrapperRect = ripple.getBoundingClientRect();
|
const wrapperRect = ripple.getBoundingClientRect();
|
||||||
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
|
const diameter = Math.max(wrapperRect.width, wrapperRect.height);
|
||||||
const radius = diameter / 2;
|
const radius = diameter / 2;
|
||||||
const left = x - wrapperRect.left - radius;
|
const left = px(x - wrapperRect.left - radius);
|
||||||
const top = y - wrapperRect.top - radius;
|
const top = px(y - wrapperRect.top - radius);
|
||||||
return { left, top, width: diameter, height: diameter };
|
return { left, top, width: px(diameter), height: px(diameter) };
|
||||||
};
|
};
|
||||||
|
119
front/src/components/ui/autocomplete/component.tsx
Normal file
119
front/src/components/ui/autocomplete/component.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
|
||||||
|
import { useMissClick } from '@utils/miss-click';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, {
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { Menu } from '../menu';
|
||||||
|
import { Popover } from '../popover';
|
||||||
|
import { TextInput } from '../text-input';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { AutocompleteProps } from './types';
|
||||||
|
|
||||||
|
function AutocompleteInner<T>(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
getOptionKey,
|
||||||
|
getOptionLabel,
|
||||||
|
onChange,
|
||||||
|
scale = 'm',
|
||||||
|
label = {},
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
}: Omit<AutocompleteProps<T>, 'ref'>,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLUListElement | null>(null);
|
||||||
|
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [menuVisible, setMenuVisible] = useState<boolean>(false);
|
||||||
|
const [text, setText] = useState<string>('');
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => autocompleteRef.current, []);
|
||||||
|
|
||||||
|
useMissClick({
|
||||||
|
callback: () => setMenuVisible(false),
|
||||||
|
enabled: menuVisible,
|
||||||
|
whitelist: [autocompleteRef, menuRef],
|
||||||
|
});
|
||||||
|
|
||||||
|
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
|
||||||
|
[styles.menuVisible]: menuVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions = options.filter((option) => {
|
||||||
|
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||||
|
const raw = text.trim().toLocaleLowerCase();
|
||||||
|
return label.includes(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputClick = () => {
|
||||||
|
setMenuVisible(!menuVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuSelect = (option: T) => {
|
||||||
|
setMenuVisible(false);
|
||||||
|
onChange?.(option);
|
||||||
|
setText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setText(value);
|
||||||
|
const option = options.find((option) => {
|
||||||
|
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||||
|
const raw = value.toLocaleLowerCase();
|
||||||
|
return label === raw;
|
||||||
|
});
|
||||||
|
onChange?.(option ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={autocompleteClassName} ref={autocompleteRef}>
|
||||||
|
<TextInput
|
||||||
|
value={value ? getOptionLabel(value) : text}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
scale={scale}
|
||||||
|
label={label}
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
wrapper={{ ref: inputWrapperRef }}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
rightNode={
|
||||||
|
<div className={styles.iconBox}>
|
||||||
|
<ArrowDownIcon className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
visible={menuVisible}
|
||||||
|
anchorRef={autocompleteRef}
|
||||||
|
position="bottom"
|
||||||
|
horizontalAlign="stretch"
|
||||||
|
flip
|
||||||
|
element={
|
||||||
|
<div className={styles.menuWrapper}>
|
||||||
|
<Menu
|
||||||
|
options={filteredOptions}
|
||||||
|
selected={value}
|
||||||
|
getOptionKey={getOptionKey}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
onSelect={handleMenuSelect}
|
||||||
|
ref={menuRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
|
||||||
|
props: AutocompleteProps<T>,
|
||||||
|
) => ReturnType<typeof AutocompleteInner>;
|
3
front/src/components/ui/autocomplete/index.ts
Normal file
3
front/src/components/ui/autocomplete/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { Autocomplete } from './component';
|
||||||
|
export { AutocompletePreview } from './preview';
|
||||||
|
export { type AutocompleteProps } from './types';
|
44
front/src/components/ui/autocomplete/preview.tsx
Normal file
44
front/src/components/ui/autocomplete/preview.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { PreviewArticle } from '@components/ui/preview';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Autocomplete } from './component';
|
||||||
|
|
||||||
|
export function AutocompletePreview() {
|
||||||
|
const [selectValue, setSelectValue] = useState<string>();
|
||||||
|
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewArticle title="Autocomplete">
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="s"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="m"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionKey={(o) => o}
|
||||||
|
getOptionLabel={(o) => o}
|
||||||
|
label={{ text: 'Select your favorite fruit' }}
|
||||||
|
scale="l"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(o) => setSelectValue(o)}
|
||||||
|
name="fruit"
|
||||||
|
/>
|
||||||
|
</PreviewArticle>
|
||||||
|
);
|
||||||
|
}
|
62
front/src/components/ui/autocomplete/styles.module.scss
Normal file
62
front/src/components/ui/autocomplete/styles.module.scss
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
|
.autocomplete {
|
||||||
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--clr-text-100);
|
||||||
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuVisible {
|
||||||
|
.icon {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuWrapper {
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$padding-right: 7px;
|
||||||
|
$size: 10px;
|
||||||
|
|
||||||
|
.s {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: $padding-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: $size;
|
||||||
|
height: $size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: f.m($padding-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.m($size);
|
||||||
|
height: f.m($size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
.iconBox {
|
||||||
|
padding-right: f.l($padding-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: f.l($size);
|
||||||
|
height: f.l($size);
|
||||||
|
}
|
||||||
|
}
|
16
front/src/components/ui/autocomplete/types.ts
Normal file
16
front/src/components/ui/autocomplete/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ComponentProps, Key } from 'react';
|
||||||
|
|
||||||
|
import { LabelProps } from '../label';
|
||||||
|
import { Scale } from '../types';
|
||||||
|
|
||||||
|
export type AutocompleteProps<T> = {
|
||||||
|
options: T[];
|
||||||
|
value?: T;
|
||||||
|
getOptionKey: (option: T) => Key;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
onChange?: (option: T) => void;
|
||||||
|
scale?: Scale;
|
||||||
|
label?: LabelProps;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
@ -1,23 +1,25 @@
|
|||||||
import clsx from 'clsx';
|
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 { Comet } from '../comet';
|
||||||
import { RawButton } from '../raw';
|
import { RawButton } from '../raw';
|
||||||
import { COMET_VARIANT_MAP } from './constants';
|
import { COMET_VARIANT_MAP } from './constants';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { ButtonProps } from './types.js';
|
import { ButtonProps } from './types.js';
|
||||||
|
|
||||||
export function Button({
|
function ButtonInner(
|
||||||
variant = 'primary',
|
{
|
||||||
scale = 'm',
|
variant = 'primary',
|
||||||
pending = false,
|
scale = 'm',
|
||||||
className,
|
pending = false,
|
||||||
children,
|
className,
|
||||||
disabled,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps,
|
||||||
const classNames = clsx(
|
ref: ForwardedRef<HTMLButtonElement>,
|
||||||
|
) {
|
||||||
|
const buttonClassName = clsx(
|
||||||
styles.button,
|
styles.button,
|
||||||
styles[variant],
|
styles[variant],
|
||||||
styles[scale],
|
styles[scale],
|
||||||
@ -25,11 +27,7 @@ export function Button({
|
|||||||
className,
|
className,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<RawButton
|
<RawButton className={buttonClassName} ref={ref} {...props}>
|
||||||
className={classNames}
|
|
||||||
disabled={pending ? true : disabled}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{pending && (
|
{pending && (
|
||||||
<div className={styles.cometWrapper}>
|
<div className={styles.cometWrapper}>
|
||||||
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
|
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
|
||||||
@ -40,3 +38,5 @@ export function Button({
|
|||||||
</RawButton>
|
</RawButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef(ButtonInner);
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all var(--td-100) ease-in-out;
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled) {
|
&:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@ -26,6 +23,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pending {
|
.pending {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
.childrenWrapper {
|
.childrenWrapper {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
@ -35,12 +34,10 @@
|
|||||||
background-color: var(--clr-primary);
|
background-color: var(--clr-primary);
|
||||||
color: var(--clr-on-primary);
|
color: var(--clr-on-primary);
|
||||||
|
|
||||||
&:hover {
|
@media (hover: hover) {
|
||||||
background-color: var(--clr-primary-hover);
|
&:hover {
|
||||||
}
|
background-color: var(--clr-primary-hover);
|
||||||
|
}
|
||||||
&.pending {
|
|
||||||
background-color: var(--clr-primary-active);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,29 +45,31 @@
|
|||||||
background-color: var(--clr-secondary);
|
background-color: var(--clr-secondary);
|
||||||
color: var(--clr-on-secondary);
|
color: var(--clr-on-secondary);
|
||||||
|
|
||||||
&:hover {
|
@media (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 {
|
.s {
|
||||||
padding: 10px 16px;
|
padding: $padding;
|
||||||
border-radius: 8px;
|
border-radius: $border-radius;
|
||||||
font-size: 12px;
|
font-size: $font-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
padding: 14px 20px;
|
padding: f.m($padding);
|
||||||
border-radius: 10px;
|
border-radius: f.m($border-radius);
|
||||||
font-size: 16px;
|
font-size: f.m($font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
padding: 18px 24px;
|
padding: f.l($padding);
|
||||||
border-radius: 12px;
|
border-radius: f.l($border-radius);
|
||||||
font-size: 20px;
|
font-size: f.l($font-size);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { CalendarDays } from './parts';
|
import { CalendarDays } from './components';
|
||||||
import { CalendarProps } from './types';
|
import { CalendarProps } from './types';
|
||||||
|
|
||||||
function CalendarInner(
|
function CalendarInner(
|
||||||
|
@ -33,7 +33,7 @@ export function CalendarDays({
|
|||||||
}, [date, min, max]);
|
}, [date, min, max]);
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
onChange?.(newValue);
|
onChange(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
@ -40,18 +40,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--clr-text-100);
|
color: var(--clr-text-100);
|
||||||
|
cursor: pointer;
|
||||||
transition: all var(--td-100) ease-in-out;
|
transition: all var(--td-100) ease-in-out;
|
||||||
|
|
||||||
&:not(:disabled) {
|
&:hover {
|
||||||
cursor: pointer;
|
background-color: var(--clr-layer-300-hover);
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--clr-layer-300-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: var(--clr-text-100);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ export type CalendarDay = {
|
|||||||
|
|
||||||
export type CalendarDaysProps = {
|
export type CalendarDaysProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
min: Date | null;
|
min: Date | null;
|
||||||
max: Date | null;
|
max: Date | null;
|
||||||
date: Date;
|
date: Date;
|
@ -1,11 +1,18 @@
|
|||||||
import { dateToInputString } from '@utils/date';
|
|
||||||
|
|
||||||
import { CalendarDay, GetCalendarDaysParams } from './types';
|
import { CalendarDay, GetCalendarDaysParams } from './types';
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const addDays = (date: Date, days: number) => {
|
||||||
date.setDate(date.getDate() + days);
|
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) => {
|
const daysAreEqual = (date1: Date, date2: Date) => {
|
||||||
return (
|
return (
|
||||||
date1.getDate() === date2.getDate() &&
|
date1.getDate() === date2.getDate() &&
|
@ -1,6 +1,8 @@
|
|||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
export type CalendarProps = {
|
export type CalendarProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
min: Date | null;
|
min: Date | null;
|
||||||
max: Date | null;
|
max: Date | null;
|
||||||
} & Omit<React.ComponentProps<'div'>, 'onChange'>;
|
} & Omit<ComponentProps<'div'>, 'onChange'>;
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.checkBoxGroup {
|
.checkBoxGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$margin-bottom: 4px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 3px;
|
margin-bottom: $margin-bottom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 5px;
|
margin-bottom: f.m($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
.label {
|
.label {
|
||||||
margin-bottom: 7px;
|
margin-bottom: f.l($margin-bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { ForwardedRef, forwardRef } from 'react';
|
import React, { ForwardedRef, forwardRef } from 'react';
|
||||||
|
|
||||||
import { Ripple } from '../animation';
|
import { Ripple } from '../animation/ripple/component';
|
||||||
import { Label, LabelProps } from '../label';
|
import { Label, LabelProps } from '../label';
|
||||||
import { RawInput } from '../raw';
|
import { RawInput } from '../raw';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@ -54,35 +57,40 @@
|
|||||||
transition: all var(--td-100) ease-in-out;
|
transition: all var(--td-100) ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$padding-outer: 4px;
|
||||||
|
$size: 16px;
|
||||||
|
$padding-inner: 2px;
|
||||||
|
$border-radius: 5px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
padding: 3px;
|
padding: $padding-outer;
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 16px;
|
width: $size;
|
||||||
height: 16px;
|
height: $size;
|
||||||
padding: 2px;
|
padding: $padding-inner;
|
||||||
border-radius: 5px;
|
border-radius: $border-radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
padding: 5px;
|
padding: f.m($padding-outer);
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 20px;
|
width: f.m($size);
|
||||||
height: 20px;
|
height: f.m($size);
|
||||||
padding: 3px;
|
padding: f.m($padding-inner);
|
||||||
border-radius: 6px;
|
border-radius: f.m($border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
padding: 7px;
|
padding: f.l($padding-outer);
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 24px;
|
width: f.l($size);
|
||||||
height: 24px;
|
height: f.l($size);
|
||||||
padding: 4px;
|
padding: f.l($padding-inner);
|
||||||
border-radius: 7px;
|
border-radius: f.l($border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use '@components/func.scss' as f;
|
||||||
|
|
||||||
.comet {
|
.comet {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spinner-comet 1s infinite linear;
|
animation: spinner-comet 1s infinite linear;
|
||||||
@ -9,23 +11,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$size: 12px;
|
||||||
|
$offset: 1.75px;
|
||||||
|
|
||||||
.s {
|
.s {
|
||||||
width: 12px;
|
width: $size;
|
||||||
height: 12px;
|
height: $size;
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
#0000 calc(100% - $offset),
|
||||||
|
#000 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m {
|
.m {
|
||||||
width: 16px;
|
width: f.m($size);
|
||||||
height: 16px;
|
height: f.m($size);
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
#0000 calc(100% - f.m($offset)),
|
||||||
|
#000 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.l {
|
||||||
width: 20px;
|
width: f.l($size);
|
||||||
height: 20px;
|
height: f.l($size);
|
||||||
mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
|
mask: radial-gradient(
|
||||||
}
|
farthest-side,
|
||||||
|
#0000 calc(100% - f.l($offset)),
|
||||||
|
#000 0
|
||||||
|
);}
|
||||||
|
|
||||||
.onPrimary {
|
.onPrimary {
|
||||||
background: conic-gradient(#0000 10%, var(--clr-on-primary));
|
background: conic-gradient(#0000 10%, var(--clr-on-primary));
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
import { Scale } from '../types';
|
import { Scale } from '../types';
|
||||||
|
|
||||||
export type CometProps = {
|
export type CometProps = {
|
||||||
scale?: Scale;
|
scale?: Scale;
|
||||||
variant?: 'onPrimary' | 'onSecondary';
|
variant?: 'onPrimary' | 'onSecondary';
|
||||||
} & React.ComponentProps<'div'>;
|
} & ComponentProps<'div'>;
|
||||||
|
77
front/src/components/ui/data-grid/component.tsx
Normal file
77
front/src/components/ui/data-grid/component.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { DataGridHeader, DataGridRow } from './components';
|
||||||
|
import { DataGridProps } from './types';
|
||||||
|
|
||||||
|
export function DataGrid<T>({
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
getItemKey,
|
||||||
|
selectedItems,
|
||||||
|
onItemsSelect,
|
||||||
|
multiselect,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DataGridProps<T>) {
|
||||||
|
const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const columnsTemplate = useMemo(() => {
|
||||||
|
const main = columns.map((c) => `${c.width ?? 1}fr`).join(' ');
|
||||||
|
return `auto ${main}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedItemsMap = useMemo(() => {
|
||||||
|
const map: Record<string, T> = {};
|
||||||
|
for (let i = 0; i < selectedItems.length; i += 1) {
|
||||||
|
const item = selectedItems[i];
|
||||||
|
map[String(getItemKey(item))] = item;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [selectedItems]);
|
||||||
|
|
||||||
|
const handleSelectAllItems = () => {
|
||||||
|
if (!multiselect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onItemsSelect?.(allItemsSelected ? [] : [...items]);
|
||||||
|
setAllItemsSelected(!allItemsSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemSelect = (key: string, item: T) => {
|
||||||
|
const selected = Boolean(selectedItemsMap[key]);
|
||||||
|
if (!multiselect) {
|
||||||
|
onItemsSelect?.(selected ? [] : [item]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onItemsSelect?.(
|
||||||
|
selected
|
||||||
|
? selectedItems.filter((i) => key !== getItemKey(i))
|
||||||
|
: [...selectedItems, item],
|
||||||
|
);
|
||||||
|
setAllItemsSelected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} {...props}>
|
||||||
|
<DataGridHeader
|
||||||
|
columns={columns}
|
||||||
|
allItemsSelected={allItemsSelected}
|
||||||
|
onSelectAllItems={handleSelectAllItems}
|
||||||
|
columnsTemplate={columnsTemplate}
|
||||||
|
/>
|
||||||
|
{items.map((item) => {
|
||||||
|
const key = String(getItemKey(item));
|
||||||
|
return (
|
||||||
|
<DataGridRow
|
||||||
|
object={item}
|
||||||
|
columns={columns}
|
||||||
|
selected={Boolean(selectedItemsMap[key])}
|
||||||
|
onSelect={() => handleItemSelect(key, item)}
|
||||||
|
key={getItemKey(item)}
|
||||||
|
columnsTemplate={columnsTemplate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
import { Ripple } from '@components/ui/animation';
|
||||||
|
import { Checkbox } from '@components/ui/checkbox';
|
||||||
|
import { RawButton } from '@components/ui/raw';
|
||||||
|
import { Span } from '@components/ui/span';
|
||||||
|
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { DataGridSort } from '../../types';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { DataGridHeaderProps } from './types';
|
||||||
|
|
||||||
|
export function DataGridHeader<T>({
|
||||||
|
columns,
|
||||||
|
allItemsSelected,
|
||||||
|
onSelectAllItems,
|
||||||
|
columnsTemplate,
|
||||||
|
}: DataGridHeaderProps<T>) {
|
||||||
|
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
|
||||||
|
|
||||||
|
const handleSortButtonClick = (column: string) => {
|
||||||
|
if (column === sort.column) {
|
||||||
|
if (sort.order === 'asc') {
|
||||||
|
setSort({ order: 'desc', column });
|
||||||
|
} else {
|
||||||
|
setSort({ order: 'desc', column: '' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSort({ order: 'asc', column });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={styles.header}
|
||||||
|
style={{ gridTemplateColumns: columnsTemplate }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={allItemsSelected}
|
||||||
|
onChange={onSelectAllItems}
|
||||||
|
label={{ className: styles.checkboxLabel }}
|
||||||
|
/>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isActive = sort.column === column.name;
|
||||||
|
const cellClassName = clsx(styles.cell, {
|
||||||
|
[styles.activeCell]: isActive,
|
||||||
|
[styles.desc]: isActive && sort.order === 'desc',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<RawButton
|
||||||
|
className={cellClassName}
|
||||||
|
key={column.name}
|
||||||
|
onClick={() => handleSortButtonClick(column.name)}
|
||||||
|
>
|
||||||
|
<Span color="t300" className={styles.name}>
|
||||||
|
{column.name}
|
||||||
|
</Span>
|
||||||
|
<ArrowUpIcon className={styles.icon} />
|
||||||
|
<Ripple />
|
||||||
|
</RawButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * from './component';
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { DataGridColumnConfig } from '../../types';
|
||||||
|
|
||||||
|
export type DataGridHeaderProps<T> = {
|
||||||
|
columns: DataGridColumnConfig<T>[];
|
||||||
|
allItemsSelected: boolean;
|
||||||
|
onSelectAllItems: () => void;
|
||||||
|
columnsTemplate: string;
|
||||||
|
};
|
@ -0,0 +1,32 @@
|
|||||||
|
import { Checkbox } from '@components/ui/checkbox';
|
||||||
|
import { Span } from '@components/ui/span';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { DataGridRowProps } from './types';
|
||||||
|
|
||||||
|
export function DataGridRow<T>({
|
||||||
|
object,
|
||||||
|
columns,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
columnsTemplate,
|
||||||
|
}: DataGridRowProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ gridTemplateColumns: columnsTemplate }}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
label={{ className: styles.checkboxLabel }}
|
||||||
|
onChange={onSelect}
|
||||||
|
/>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div className={styles.cell} key={column.name}>
|
||||||
|
<Span>{column.getText(object)}</Span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * from './component';
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataGridColumnConfig } from '../../types';
|
||||||
|
|
||||||
|
export type DataGridRowProps<T> = {
|
||||||
|
object: T;
|
||||||
|
columns: DataGridColumnConfig<T>[];
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
columnsTemplate: string;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user