Initialize project structure with client and server setup, including environment configuration, package management, and essential assets. Added .env.example, .gitignore, package.json, pnpm workspace configuration, and client-side files including HTML, CSS, and SVG assets.
45
.env.example
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
MONGODB_URI=mongodb://127.0.0.1:27017/toms-allotment
|
||||||
|
SESSION_SECRET=replace-with-long-random-string
|
||||||
|
|
||||||
|
# Browser origin (CORS + session). In dev with Vite, use the Vite dev server URL so cookies work through the proxy.
|
||||||
|
CLIENT_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# OIDC (optional — omit to disable sign-in; watering stays disabled)
|
||||||
|
OIDC_ISSUER=https://your-idp.example.com/realms/your-realm
|
||||||
|
OIDC_CLIENT_ID=toms-allotment
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
# In dev, use Vite proxy so the callback is same-site with the UI:
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
|
|
||||||
|
# MQTT (optional — copy config/mqtt.example.json to config/mqtt.json)
|
||||||
|
MQTT_CONFIG_PATH=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
|
||||||
|
# Single TTN device (optional — overrides packages/server/config.json device.*)
|
||||||
|
# DEMO_* still supported; DEVICE_TTN_* wins if both are set.
|
||||||
|
DEMO_TTN_APP_ID=demo-app
|
||||||
|
DEMO_TTN_DEV_ID=demo-device
|
||||||
|
# DEVICE_TTN_APPLICATION_ID=
|
||||||
|
# DEVICE_TTN_DEVICE_ID=
|
||||||
|
# DEVICE_NAME=
|
||||||
|
|
||||||
|
# S3-compatible storage (patch images). Prefer env for secrets — do not commit keys.
|
||||||
|
# Cloudflare R2: endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com (no bucket in path).
|
||||||
|
# Region is forced to "auto" for R2 when the endpoint host is r2.cloudflarestorage.com.
|
||||||
|
# MinIO: set S3_ENDPOINT (e.g. http://localhost:9000) and S3_FORCE_PATH_STYLE=true
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_PREFIX=allotment-images
|
||||||
|
S3_ENDPOINT=
|
||||||
|
# R2 API token or MinIO access key (AWS_* also work if unset here)
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
# Optional (temporary creds / assume-role)
|
||||||
|
S3_SESSION_TOKEN=
|
||||||
|
S3_FORCE_PATH_STYLE=
|
||||||
|
AWS_REGION=
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_SESSION_TOKEN=
|
||||||
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
14
package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "toms-allotment",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"pnpm --filter @toms-allotment/server dev\" \"pnpm --filter @toms-allotment/client dev\"",
|
||||||
|
"build": "pnpm --filter @toms-allotment/client build",
|
||||||
|
"start": "pnpm --filter @toms-allotment/client build && pnpm --filter @toms-allotment/server start"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
10329
packages/client/assets/lottie/no-flower-plant.json
Normal file
BIN
packages/client/assets/lottie/water-animation.lottie
Normal file
BIN
packages/client/assets/png/pcb-board.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
packages/client/assets/png/solar-panel.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
7
packages/client/assets/svg/Loading.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="rect3795" transform="matrix(0.707107,-0.707107,0.707107,0.707107,-20.743783,50.125111)">
|
||||||
|
<path d="M98.75,50C98.75,52.622 96.622,54.75 94,54.75L75,54.75C72.378,54.75 70.25,52.622 70.25,50C70.25,47.378 72.378,45.25 75,45.25L94,45.25C96.622,45.25 98.75,47.378 98.75,50ZM84.583,84.406C82.73,86.26 79.72,86.26 77.866,84.406L64.431,70.971C62.577,69.118 62.577,66.108 64.431,64.254C66.285,62.4 69.295,62.4 71.148,64.254L84.583,77.689C86.437,79.543 86.437,82.553 84.583,84.406ZM50.144,98.54C47.522,98.54 45.394,96.412 45.394,93.79L45.394,74.79C45.394,72.169 47.522,70.04 50.144,70.04C52.766,70.04 54.894,72.169 54.894,74.79L54.894,93.79C54.894,96.412 52.766,98.54 50.144,98.54ZM15.64,84.406C13.787,82.553 13.787,79.543 15.64,77.689L29.075,64.254C30.929,62.4 33.939,62.4 35.793,64.254C37.647,66.108 37.647,69.118 35.793,70.971L22.358,84.406C20.504,86.26 17.494,86.26 15.64,84.406ZM64.431,35.616C62.577,33.762 62.577,30.752 64.431,28.899L77.866,15.463C79.72,13.61 82.73,13.61 84.583,15.463C86.437,17.317 86.437,20.327 84.583,22.181L71.148,35.616C69.295,37.47 66.285,37.47 64.431,35.616ZM1.474,49.87C1.474,47.248 3.602,45.12 6.224,45.12L25.224,45.12C27.845,45.12 29.974,47.248 29.974,49.87C29.974,52.491 27.845,54.62 25.224,54.62L6.224,54.62C3.602,54.62 1.474,52.491 1.474,49.87ZM15.64,15.463C17.494,13.61 20.504,13.61 22.358,15.463L35.793,28.899C37.647,30.752 37.647,33.762 35.793,35.616C33.939,37.47 30.929,37.47 29.075,35.616L15.64,22.181C13.787,20.327 13.787,17.317 15.64,15.463ZM50.218,1.125C52.84,1.125 54.968,3.254 54.968,5.875L54.968,24.875C54.968,27.497 52.84,29.625 50.218,29.625C47.597,29.625 45.468,27.497 45.468,24.875L45.468,5.875C45.468,3.254 47.597,1.125 50.218,1.125Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1186
packages/client/assets/svg/PatchDry.svg
Normal file
|
After Width: | Height: | Size: 111 KiB |
1186
packages/client/assets/svg/PatchNormal.svg
Normal file
|
After Width: | Height: | Size: 111 KiB |
1186
packages/client/assets/svg/PatchWet.svg
Normal file
|
After Width: | Height: | Size: 110 KiB |
3
packages/client/assets/svg/produce/apple.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M576 106.666667s-64 65.493333-64 192.746666" fill="#FFFFFF" /><path d="M512 299.413333S485.333333 32 213.333333 128c29.333333 278.229333 298.666667 171.413333 298.666667 171.413333z" fill="#4CAF50" /><path d="M134.08 553.258667C94.293333 338.474667 253.418667 123.669333 512 299.413333c258.581333-175.744 417.706667 39.061333 377.92 253.824C850.154667 768.042667 631.338667 969.685333 512 826.624c-119.338667 143.061333-338.154667-58.581333-377.92-273.365333z" fill="#D32F2F" /><path d="M690.816 277.333333c-46.72 0-98.816 19.306667-154.837333 57.365334L512 350.997333v409.024l32.768 39.274667c18.346667 22.016 39.189333 32.725333 63.68 32.725333 84.458667 0 210.133333-127.808 239.509333-286.549333 15.018667-81.024-2.325333-163.413333-45.248-215.04C773.802667 295.701333 735.104 277.333333 690.816 277.333333z" fill="#FFF3E0" /><path d="M672 559.082667a42.666667 42.666667 0 0 1-85.333333 0c0-23.573333 42.666667-72.832 42.666666-72.832s42.666667 49.28 42.666667 72.832z" fill="#5D4037" /><path d="M512 299.413333C253.418667 123.669333 94.293333 338.474667 134.08 553.258667 173.845333 768.042667 392.661333 969.685333 512 826.624V299.413333z" fill="#F44336" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
3
packages/client/assets/svg/produce/beet.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M598.08 426.666667c-36.757333 0-64 12.117333-85.333333 12.117333s-53.098667-12.117333-85.333334-12.117333c-117.098667 0-192 85.12-192 194.005333 0 108.885333 85.333333 211.328 179.242667 211.328h-8.576c85.333333 0 106.666667 85.333333 106.666667 85.333333s21.333333-85.333333 118.186666-85.781333h-8.554666C704.746667 832 789.333333 729.557333 789.333333 620.672S720.170667 426.773333 598.08 426.666667z" fill="#D81B60" /><path d="M444.437333 467.562667a67.562667 67.562667 0 0 0 135.125334 0c0-14.293333-4.736-27.306667-12.352-38.208-22.08 3.562667-40.213333 9.429333-55.210667 9.429333-14.634667 0-34.282667-5.568-55.509333-9.152a66.666667 66.666667 0 0 0-12.053334 37.930667z" fill="#AD1457" /><path d="M362.666667 576h65.792M597.333333 597.333333h85.333334M350.208 682.666667H490.666667M526.677333 768H618.666667" fill="#AD1457" /><path d="M811.946667 157.184c-8.512-8.512 0-4.266667 2.154666-10.666667 4.266667-8.512-12.821333-10.666667-21.333333-10.666666h-4.266667c-23.466667-2.133333 2.133333-6.4 0-17.066667s-27.733333-8.533333-34.133333-8.533333c-12.821333 2.154667-27.754667 6.421333-38.421333 12.821333 4.266667-2.154667 10.666667-10.666667 6.421333-14.933333-4.266667-4.266667-21.333333 2.133333-27.754667 2.133333-12.8 4.266667-23.466667 6.421333-36.245333 12.821333-8.533333 4.266667-14.933333 12.8-23.488 19.178667 0-4.245333 2.154667-8.512 2.154667-12.8-19.2-4.266667-51.2 36.266667-49.088 55.466667 0-8.512 0-23.466667-10.666667-14.912-4.245333 8.512-66.133333 74.666667-66.133333 213.333333v65.557333c0 3.264 0.362667 6.122667 0.853333 8.768V490.666667c10.645333 0 19.733333-8.149333 21.333333-18.325334 0.213333-0.106667-0.213333 0.106667 0 0 4.330667-29.994667 11.946667-61.269333 24.746667-97.536 8.512-29.866667 21.333333-64 55.466667-70.4 8.533333-2.154667 21.333333 2.133333 29.866666-6.4-2.154667-2.154667-10.666667-25.6-12.821333-27.754666 10.666667 2.154667 21.333333 19.2 34.154667 21.333333 17.066667 2.154667 19.178667-8.512 21.333333-21.333333 6.4 10.666667 21.333333 19.2 32 21.333333 12.8 2.154667 23.466667-8.512 14.933333-21.333333 12.8 8.533333 59.733333 34.154667 59.733334 0 0-19.178667-17.066667-46.912-34.154667-53.333334 12.821333 0 32 8.533333 46.933333 6.421334 12.821333 0 21.333333-8.533333 25.6-21.333334 4.288-19.221333-8.512-32-19.178666-44.821333z" fill="#4CAF50" /><path d="M512 383.338667c0-138.666667-61.866667-204.821333-66.133333-213.333334-10.666667-8.533333-10.666667 6.4-10.666667 14.912 2.133333-19.178667-29.866667-59.733333-49.088-55.466666 0 4.266667 2.154667 8.533333 2.154667 12.8-8.533333-6.4-14.933333-14.912-23.488-19.178667-12.8-6.4-23.466667-8.533333-36.245334-12.821333-6.421333 0-23.488-6.4-27.754666-2.133334-4.245333 4.266667 2.154667 12.8 6.421333 14.933334-10.666667-6.4-25.6-10.666667-38.421333-12.821334-6.4 0-32-2.133333-34.133334 8.533334s23.466667 14.933333 0 17.066666h-4.266666c-8.512 0-25.6 2.154667-21.333334 10.666667 2.154667 6.4 10.666667 2.154667 2.154667 10.666667-10.666667 12.842667-23.466667 25.621333-19.2 44.842666 4.266667 12.8 12.8 21.333333 25.6 21.333334 14.933333 2.133333 34.133333-6.421333 46.933333-6.421334-17.088 6.421333-34.154667 34.154667-34.154666 53.333334 0 34.154667 46.933333 8.533333 59.733333 0-8.512 12.821333 2.154667 23.488 14.933333 21.333333 10.666667-2.133333 25.6-10.666667 32-21.333333 2.154667 12.821333 4.266667 23.488 21.333334 21.333333 12.821333-2.133333 23.488-19.178667 34.154666-21.333333-2.154667 2.154667-10.666667 25.6-12.821333 27.754666 8.533333 8.512 21.333333 4.245333 29.866667 6.4 34.133333 6.4 46.933333 40.512 55.466666 70.4 13.44 38.016 21.610667 70.208 25.6 94.549334a21.333333 21.333333 0 0 0 21.333334 21.333333v-42.666667L512 383.338667z" fill="#388E3C" /></svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
3
packages/client/assets/svg/produce/cabbage.svg
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
27
packages/client/assets/svg/produce/carrot.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="Layer_3" transform="matrix(25,0,0,25,0,0)">
|
||||||
|
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
|
||||||
|
<path d="M14.419,21.806L10.394,16.767C9.726,15.931 9.73,14.741 10.404,13.909C10.404,13.909 14.211,11.216 15.952,10.018C16.912,9.362 18.212,9.534 18.969,10.417C20.513,12.218 22.843,14.937 22.843,14.937L22.824,14.953C22.99,15.162 23.152,15.374 23.31,15.59L23.48,15.83C27.19,20.93 26.45,27.73 26.44,27.99C26.421,28.125 26.349,28.247 26.24,28.33C26.152,28.393 26.048,28.427 25.94,28.43L25.86,28.43C25.623,28.38 22.09,27.809 18.657,25.672C17.734,25.193 16.789,24.453 15.825,23.452C15.7,23.321 15.578,23.189 15.457,23.055C15.263,22.844 15.074,22.625 14.89,22.398C14.73,22.203 14.573,22.006 14.419,21.806Z" style="fill:rgb(255,196,0);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
|
||||||
|
<path d="M13.132,20.195L13.308,20.012C13.388,19.924 13.499,19.87 13.618,19.862C13.848,19.862 19.459,19.189 21.179,14.179C21.226,14.058 21.314,13.959 21.429,13.899L21.783,13.701C22.405,14.426 22.843,14.937 22.843,14.937L22.824,14.953C22.99,15.162 23.152,15.374 23.31,15.59L23.48,15.83C23.518,15.882 23.555,15.935 23.593,15.987C23.576,16.051 23.531,16.117 23.499,16.189C21.599,21.699 15.739,22.399 15.269,22.449L15.012,22.546C14.971,22.497 14.931,22.448 14.89,22.398C14.73,22.203 14.573,22.006 14.419,21.806L13.132,20.195Z" style="fill:rgb(255,171,0);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,9.362665,13.283662)">
|
||||||
|
<path d="M16.961,18.696C17.485,18.429 18.024,18.093 18.542,17.674C18.523,18.128 18.495,18.407 18.493,18.456C18.474,18.591 18.402,18.713 18.293,18.796C18.205,18.858 18.101,18.893 17.993,18.896L17.913,18.896C17.845,18.881 17.5,18.823 16.961,18.696Z" style="fill:rgb(255,171,0);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,5.36948,9.341635)">
|
||||||
|
<path d="M13.307,19.933L13.376,19.861C13.456,19.773 13.568,19.719 13.686,19.711C13.916,19.711 19.34,19.08 21.06,14.07C21.067,14.053 21.074,14.036 21.082,14.02C21.679,15.524 22.015,17.035 22.198,18.379C20.66,20.516 18.446,21.512 16.905,21.97C16.143,21.63 15.344,21.22 14.55,20.726C14.14,20.513 13.726,20.249 13.307,19.933Z" style="fill:rgb(255,171,0);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
|
||||||
|
<path d="M11.093,17.642L10.394,16.767C9.979,16.247 9.823,15.591 9.927,14.969C10.689,14.885 15.385,14.075 17.098,9.625C17.79,9.582 18.489,9.856 18.969,10.417C19.171,10.653 19.387,10.905 19.61,11.164C19.59,11.222 19.551,11.281 19.523,11.345C17.623,16.855 11.763,17.555 11.293,17.605L11.093,17.642Z" style="fill:rgb(255,171,0);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(-0.024981,-0.001144,-0.001144,0.024981,27.145456,1.349857)">
|
||||||
|
<path d="M640,426.667C640,426.667 740.267,341.333 814.933,341.333C864,341.333 917.333,373.333 917.333,430.933C917.333,477.867 904.533,533.333 846.933,533.333C791.467,533.333 791.467,490.667 787.2,473.6C774.4,488.533 714.667,507.733 697.6,458.667C697.6,458.667 605.867,486.4 539.733,535.467L640,426.667ZM593.067,448C659.2,371.2 603.733,192 503.467,192C426.667,192 405.333,230.4 405.333,264.533C405.333,294.4 435.2,330.667 477.867,324.267C469.333,377.6 518.4,401.067 554.667,373.333C576,379.733 550.4,494.933 492.8,548.267L593.067,448Z" style="fill:rgb(76,175,80);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(-0.024981,-0.001144,-0.001144,0.024981,27.145456,1.349857)">
|
||||||
|
<path d="M706.133,85.333C659.2,85.333 618.667,110.933 618.667,149.333C618.667,200.533 676.267,194.133 676.267,194.133C676.267,194.133 627.2,253.867 712.533,270.933C708.267,292.267 676.267,332.8 631.467,381.867L433.067,580.267C424.533,588.8 424.533,601.6 433.067,610.133C437.333,614.4 443.733,616.533 448,616.533C452.267,616.533 458.667,614.4 462.933,610.133L516.267,556.8C563.2,514.133 620.8,454.4 689.067,384C689.067,384 750.933,320 765.867,302.933C870.4,198.4 853.333,85.333 706.133,85.333Z" style="fill:rgb(56,142,60);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
3
packages/client/assets/svg/produce/cherry.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M883.2 629.333333c-87.466667-264.533333-288-70.4-288-70.4s-27.733333 2.133333-59.733333 12.8c-68.266667-66.133333-196.266667-25.6-196.266667-25.6s-236.8-78.933333-209.066667 185.6c12.8 113.066667 100.266667 185.6 221.866667 185.6 61.866667 0 115.2-19.2 153.6-51.2 64 57.6 157.866667 68.266667 251.733333 25.6 115.2-51.2 162.133333-153.6 125.866667-262.4z" fill="#FF1744" /><path d="M633.6 753.066667c-8.533333-83.2 57.6-234.666667 113.066667-262.4-76.8-2.133333-151.466667 68.266667-151.466667 68.266666s-25.6 2.133333-57.6 12.8c-2.133333-2.133333-4.266667-2.133333-6.4-2.133333 29.866667 27.733333 49.066667 78.933333 40.533333 162.133333-6.4 55.466667-29.866667 100.266667-66.133333 132.266667 32 29.866667 108.8 85.333333 251.733333 27.733333-66.133333-12.8-115.2-55.466667-123.733333-138.666666z" fill="#D50000" /><path d="M215.466667 744.533333c-8.533333 0-19.2-6.4-21.333334-17.066666-21.333333-87.466667 29.866667-123.733333 51.2-136.533334 10.666667-6.4 23.466667-2.133333 27.733334 8.533334 6.4 10.666667 2.133333 23.466667-8.533334 27.733333-21.333333 10.666667-42.666667 34.133333-29.866666 87.466667 2.133333 10.666667-4.266667 23.466667-14.933334 25.6-2.133333 4.266667-2.133333 4.266667-4.266666 4.266666z" fill="#FFCDD2" /><path d="M614.4 518.4c-2.133333-6.4-4.266667-10.666667-6.4-17.066667 0-2.133333-2.133333-4.266667-2.133333-6.4-2.133333-4.266667-2.133333-8.533333-4.266667-10.666666-2.133333-4.266667-2.133333-6.4-4.266667-10.666667s-2.133333-6.4-2.133333-10.666667c-2.133333-4.266667-2.133333-8.533333-4.266667-10.666666-2.133333-4.266667-2.133333-8.533333-4.266666-12.8 0-2.133333-2.133333-4.266667-2.133334-6.4-2.133333-4.266667-2.133333-8.533333-2.133333-12.8 0-2.133333 0-4.266667-2.133333-6.4 0-4.266667-2.133333-8.533333-2.133334-12.8 0-2.133333 0-4.266667-2.133333-6.4 0-4.266667-2.133333-8.533333-2.133333-12.8v-4.266667-12.8-6.4-12.8-4.266667-12.8-4.266666-10.666667-6.4-8.533333-6.4-6.4c0-2.133333 0-4.266667 2.133333-8.533334v-4.266666c0-2.133333 2.133333-6.4 2.133333-8.533334v-4.266666c0-4.266667 2.133333-6.4 2.133334-10.666667v-2.133333c0-4.266667 2.133333-6.4 2.133333-10.666667 12.8-44.8 34.133333-74.666667 42.666667-85.333333 2.133333 0 4.266667-2.133333 4.266666-2.133334 10.666667-4.266667 14.933333-17.066667 10.666667-27.733333s-10.666667-19.2-21.333333-14.933333c0 0-2.133333 0-2.133334 2.133333-2.133333 0-4.266667 0-6.4 2.133333-2.133333 0-2.133333 2.133333-4.266666 4.266667C309.333333 249.6 320 573.866667 320 576c0 10.666667 10.666667 21.333333 21.333333 21.333333 12.8 0 21.333333-10.666667 21.333334-21.333333 0-2.133333-8.533333-249.6 196.266666-388.266667-32 78.933333-57.6 215.466667 40.533334 398.933334 4.266667 6.4 10.666667 10.666667 19.2 10.666666 4.266667 0 6.4 0 10.666666-2.133333 8.533333-4.266667 12.8-12.8 10.666667-21.333333 0-2.133333-2.133333-6.4-2.133333-8.533334-4.266667-8.533333-8.533333-17.066667-12.8-23.466666v-2.133334c-4.266667-6.4-8.533333-14.933333-10.666667-21.333333z" fill="#795548" /><path d="M810.666667 384c-38.4-40.533333 61.866667-277.333333-192-277.333333-145.066667 281.6 192 277.333333 192 277.333333z" fill="#4CAF50" /></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
3
packages/client/assets/svg/produce/corn.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M405.333333 938.666667c-106.666667 0-128-146.133333-128-405.333334s85.952-469.333333 192-469.333333 192 210.133333 192 469.333333-21.333333 405.333333-128 405.333334" fill="#EA8711" /><path d="M533.333333 789.333333a42.666667 42.666667 0 0 1-42.666666 42.666667h-42.666667a42.666667 42.666667 0 0 1 0-85.333333h42.666667a42.666667 42.666667 0 0 1 42.666666 42.666666zM533.333333 682.666667a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666666zM533.333333 576a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM533.333333 469.333333a42.666667 42.666667 0 0 0-42.666666-42.666666h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM533.333333 362.666667a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666666zM533.333333 256a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM682.666667 682.666667a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666666zM682.666667 576a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666667zM682.666667 469.333333a42.666667 42.666667 0 0 0-42.666667-42.666666h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666667zM682.666667 362.666667a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666666zM661.333333 256a42.666667 42.666667 0 0 0-42.666666-42.666667h-21.333334a42.666667 42.666667 0 0 0 0 85.333334h21.333334a42.666667 42.666667 0 0 0 42.666666-42.666667zM320 149.333333a42.666667 42.666667 0 0 0 85.333333 0 42.666667 42.666667 0 0 0-85.333333 0zM426.666667 149.333333a42.666667 42.666667 0 0 0 85.333333 0 42.666667 42.666667 0 0 0-85.333333 0zM533.333333 149.333333a42.666667 42.666667 0 0 0 85.333334 0 42.666667 42.666667 0 0 0-85.333334 0zM277.333333 256a42.666667 42.666667 0 0 0 42.666667 42.666667h21.333333a42.666667 42.666667 0 0 0 0-85.333334h-21.333333a42.666667 42.666667 0 0 0-42.666667 42.666667zM256 362.666667a42.666667 42.666667 0 0 0 42.666667 42.666666h42.666666a42.666667 42.666667 0 0 0 0-85.333333h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666667zM256 469.333333a42.666667 42.666667 0 0 0 42.666667 42.666667h42.666666a42.666667 42.666667 0 0 0 0-85.333333h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666666zM256 576a42.666667 42.666667 0 0 0 42.666667 42.666667h42.666666a42.666667 42.666667 0 0 0 0-85.333334h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666667z" fill="#FFC107" /><path d="M874.666667 719.04C617.770667 463.061333 490.666667 832 490.666667 832S400.192 589.546667 149.333333 448c166.229333 159.616 3.029333 512 332.437334 512C784 960 587.562667 601.6 874.666667 719.04z" fill="#4CAF50" /><path d="M874.666667 719.04C617.770667 463.061333 490.666667 960 490.666667 960S277.333333 512 149.333333 448c166.229333 159.616 149.333333 512 332.437334 512C682.666667 960 597.333333 554.666667 874.666667 719.04z" fill="#388E3C" /></svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
3
packages/client/assets/svg/produce/cucumber.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M938.666667 290.133333C938.666667 157.866667 910.933333 85.333333 846.933333 85.333333c-61.866667 0-70.4 68.266667-113.066666 149.333334-12.8 2.133333-23.466667 12.8-23.466667 27.733333 0 4.266667 0 8.533333 2.133333 10.666667-14.933333 25.6-29.866667 49.066667-44.8 72.533333h-4.266666c-14.933333 0-27.733333 12.8-27.733334 27.733333 0 6.4 2.133333 10.666667 4.266667 14.933334-27.733333 38.4-57.6 74.666667-85.333333 106.666666h-4.266667c-14.933333 0-27.733333 12.8-27.733333 27.733334v6.4c-29.866667 29.866667-57.6 55.466667-85.333334 76.8-12.8 0-25.6 8.533333-27.733333 21.333333-27.733333 19.2-53.333333 38.4-78.933333 53.333333-2.133333 0-4.266667-2.133333-6.4-2.133333-12.8 0-25.6 8.533333-27.733334 21.333333-25.6 14.933333-49.066667 27.733333-70.4 38.4-4.266667-2.133333-10.666667-4.266667-14.933333-4.266666-14.933333 0-27.733333 12.8-27.733333 27.733333C121.6 793.6 85.333333 817.066667 85.333333 855.466667c0 27.733333 19.2 53.333333 59.733334 68.266666 6.4 8.533333 14.933333 14.933333 25.6 14.933334 6.4 0 10.666667-2.133333 14.933333-4.266667 17.066667 2.133333 36.266667 4.266667 57.6 4.266667 29.866667 0 64-4.266667 96-8.533334 4.266667 6.4 12.8 8.533333 21.333333 8.533334 12.8 0 23.466667-8.533333 27.733334-19.2 36.266667-10.666667 74.666667-23.466667 110.933333-38.4 4.266667 2.133333 8.533333 2.133333 12.8 2.133333 12.8 0 25.6-8.533333 27.733333-21.333333 23.466667-10.666667 46.933333-25.6 68.266667-38.4 4.266667 4.266667 10.666667 4.266667 17.066667 4.266666 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c19.2-12.8 36.266667-27.733333 55.466667-42.666667 4.266667 2.133333 8.533333 2.133333 12.8 2.133334 14.933333 0 27.733333-12.8 27.733333-27.733334 0-4.266667 0-6.4-2.133333-10.666666 17.066667-17.066667 34.133333-36.266667 51.2-55.466667 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c17.066667-25.6 34.133333-51.2 49.066667-76.8 14.933333 0 27.733333-12.8 27.733333-27.733334 0-6.4-2.133333-12.8-6.4-17.066666 12.8-27.733333 21.333333-57.6 29.866667-87.466667 8.533333-4.266667 14.933333-12.8 14.933333-23.466667 0-6.4-2.133333-12.8-6.4-17.066666 2.133333-27.733333 4.266667-55.466667 4.266667-85.333334z" fill="#4CAF50" /><path d="M938.666667 290.133333C710.4 881.066667 211.2 889.6 85.333333 855.466667c0 27.733333 19.2 53.333333 59.733334 68.266666 6.4 8.533333 14.933333 14.933333 25.6 14.933334 6.4 0 10.666667-2.133333 14.933333-4.266667 17.066667 2.133333 36.266667 4.266667 57.6 4.266667 29.866667 0 64-4.266667 96-8.533334 4.266667 6.4 12.8 8.533333 21.333333 8.533334 12.8 0 23.466667-8.533333 27.733334-19.2 36.266667-10.666667 74.666667-23.466667 110.933333-38.4 4.266667 2.133333 8.533333 2.133333 12.8 2.133333 12.8 0 25.6-8.533333 27.733333-21.333333 23.466667-10.666667 46.933333-25.6 68.266667-38.4 4.266667 4.266667 10.666667 4.266667 17.066667 4.266666 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c19.2-12.8 36.266667-27.733333 55.466667-42.666667 4.266667 2.133333 8.533333 2.133333 12.8 2.133334 14.933333 0 27.733333-12.8 27.733333-27.733334 0-4.266667 0-6.4-2.133333-10.666666 17.066667-17.066667 34.133333-36.266667 51.2-55.466667 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c17.066667-25.6 34.133333-51.2 49.066667-76.8 14.933333 0 27.733333-12.8 27.733333-27.733334 0-6.4-2.133333-12.8-6.4-17.066666 12.8-27.733333 21.333333-57.6 29.866667-87.466667 8.533333-4.266667 14.933333-12.8 14.933333-23.466667 0-6.4-2.133333-12.8-6.4-17.066666 2.133333-27.733333 4.266667-55.466667 4.266667-85.333334z" fill="#388E3C" /><path d="M810.666667 256c12.8 0 21.333333 8.533333 21.333333 21.333333s-10.666667 21.333333-21.333333 21.333334c-12.8 0-21.333333-8.533333-21.333334-21.333334s8.533333-21.333333 21.333334-21.333333z m-42.666667 234.666667c12.8 0 21.333333 8.533333 21.333333 21.333333s-8.533333 21.333333-21.333333 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333333z m-64-64c12.8 0 21.333333 8.533333 21.333333 21.333333s-10.666667 21.333333-21.333333 21.333333c-12.8 0-21.333333-8.533333-21.333333-21.333333s8.533333-21.333333 21.333333-21.333333z m-85.333333 213.333333c12.8 0 21.333333 8.533333 21.333333 21.333333s-8.533333 21.333333-21.333333 21.333334-21.333333-8.533333-21.333334-21.333334 8.533333-21.333333 21.333334-21.333333z m-85.333334 21.333333c12.8 0 21.333333 8.533333 21.333334 21.333334s-8.533333 21.333333-21.333334 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z m-128 106.666667c12.8 0 21.333333 8.533333 21.333334 21.333333s-8.533333 21.333333-21.333334 21.333334-21.333333-8.533333-21.333333-21.333334 8.533333-21.333333 21.333333-21.333333z m-149.333333 21.333333c12.8 0 21.333333 8.533333 21.333333 21.333334s-8.533333 21.333333-21.333333 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z" fill="#AED581" /></svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
3
packages/client/assets/svg/produce/kiwi.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
3
packages/client/assets/svg/produce/leek.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M221.866667 851.2c-6.4-2.133333-12.8-2.133333-17.066667 2.133333 0-8.533333-4.266667-14.933333-12.8-19.2-4.266667-2.133333-8.533333-2.133333-12.8-2.133333 0-4.266667 0-6.4-2.133333-10.666667-4.266667-10.666667-17.066667-14.933333-27.733334-10.666666 0 0-2.133333 2.133333-6.4 4.266666-4.266667 2.133333-8.533333 6.4-14.933333 10.666667-10.666667 10.666667-25.6 23.466667-36.266667 40.533333-10.666667 14.933333-19.2 34.133333-23.466666 46.933334-2.133333 6.4-4.266667 12.8-4.266667 17.066666v8.533334s0-2.133333 2.133333-6.4c2.133333-4.266667 4.266667-8.533333 8.533334-14.933334 6.4-10.666667 19.2-25.6 32-36.266666s29.866667-19.2 40.533333-25.6c4.266667-2.133333 6.4-2.133333 10.666667-4.266667-2.133333 2.133333-4.266667 6.4-6.4 10.666667-6.4 12.8-17.066667 29.866667-29.866667 44.8-10.666667 17.066667-23.466667 32-36.266667 40.533333-2.133333 2.133333-6.4 4.266667-8.533333 6.4-2.133333 2.133333-4.266667 2.133333-6.4 4.266667-4.266667 0-6.4 2.133333-6.4 2.133333s2.133333 0 6.4-2.133333c2.133333 0 4.266667 0 8.533333-2.133334 2.133333 0 6.4-2.133333 10.666667-4.266666 14.933333-6.4 32-17.066667 49.066667-29.866667 17.066667-12.8 32-25.6 42.666666-38.4l8.533334-8.533333c0 2.133333-2.133333 6.4-4.266667 8.533333-6.4 12.8-12.8 29.866667-23.466667 44.8-10.666667 19.2-23.466667 32-34.133333 40.533333-6.4 4.266667-10.666667 8.533333-14.933333 8.533334-4.266667 2.133333-6.4 4.266667-6.4 4.266666s2.133333 0 6.4-2.133333c4.266667 0 10.666667-2.133333 17.066666-6.4 14.933333-6.4 32-14.933333 46.933334-29.866667 14.933333-12.8 29.866667-27.733333 38.4-40.533333 4.266667-6.4 8.533333-10.666667 10.666666-14.933333 2.133333-4.266667 4.266667-6.4 4.266667-6.4 0-2.133333 2.133333-4.266667 2.133333-6.4 6.4-6.4 0-19.2-10.666666-23.466667z" fill="#A1887F" /><path d="M234.666667 874.666667c-21.333333 0-42.666667-8.533333-59.733334-25.6-34.133333-34.133333-34.133333-87.466667 0-121.6L405.333333 533.333333c34.133333-34.133333 134.4-115.2 166.4-81.066666 34.133333 34.133333-27.733333 113.066667-59.733333 145.066666L294.4 849.066667C277.333333 866.133333 256 874.666667 234.666667 874.666667z" fill="#DCEDC8" /><path d="M981.333333 42.666667L533.333333 533.333333l-106.666666-42.666666L832 42.666667z" fill="#33691E" /><path d="M938.666667 85.333333v128L554.666667 533.333333l-128-42.666666z" fill="#558B2F" /><path d="M682.666667 149.333333h128L597.333333 469.333333h-128z" fill="#689F38" /><path d="M853.333333 341.333333L554.666667 554.666667l-85.333334-64L853.333333 192z" fill="#7CB342" /><path d="M533.333333 277.333333h128l-128 213.333334-128 42.666666z" fill="#9CCC65" /><path d="M746.666667 469.333333v-149.333333L405.333333 533.333333c108.8 0 106.666667 64 106.666667 64l234.666667-128z" fill="#AED581" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
3
packages/client/assets/svg/produce/lettuce.svg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
3
packages/client/assets/svg/produce/peas.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M903.68 84.629333c-39.232 0-155.093333 14.272-242.346667 171.370667-106.666667 192-128 213.333333-170.666666 256S234.666667 725.333333 234.666667 725.333333s-106.666667 85.333333-128 170.666667c0 0 41.450667 42.666667 127.402666 42.666667S490.666667 938.666667 725.333333 704c170.666667-192 170.666667-405.333333 170.666667-405.333333V170.666667c0-67.456 21.333333-85.333333 21.333333-85.333334s-4.928-0.704-13.653333-0.704z" fill="#7CB342" /><path d="M698.624 276.714667c-109.568 197.226667-132.352 220.032-177.792 265.450666-43.84 43.84-250.730667 209.450667-259.52 216.469334-22.570667 18.154667-78.144 70.144-103.210667 121.088 15.914667 7.872 41.045333 16.277333 75.968 16.277333 83.392 0 238.506667 0 460.202667-221.269333 156.16-176.384 159.04-374.208 159.061333-376.170667V170.666667c0-13.354667 0.725333-25.301333 2.005334-36.010667-44.586667 13.290667-105.045333 49.066667-156.714667 142.058667z" fill="#388E3C" /><path d="M736 437.333333m-53.333333 0a53.333333 53.333333 0 1 0 106.666666 0 53.333333 53.333333 0 1 0-106.666666 0Z" fill="#C5E1A5" /><path d="M789.333333 277.333333m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M640 576m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="#C5E1A5" /><path d="M522.666667 693.333333m-53.333334 0a53.333333 53.333333 0 1 0 106.666667 0 53.333333 53.333333 0 1 0-106.666667 0Z" fill="#C5E1A5" /><path d="M405.333333 789.333333m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M277.333333 832m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M917.333333 85.333333s-149.333333-21.333333-256 170.666667-128 213.333333-170.666666 256-256 213.333333-256 213.333333-106.666667 85.333333-128 170.666667c0 0 213.333333-64 448-298.666667 170.666667-192 149.333333-277.333333 277.333333-426.666666 43.904-51.221333 85.333333-85.333333 85.333333-85.333334z" fill="#8BC34A" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
3
packages/client/assets/svg/produce/plum.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M597.333333 85.333333s-64 66.133333-64 192" fill="#FFFFFF" /><path d="M832 554.666667c0 177.066667-142.933333 405.333333-320 405.333333S192 731.733333 192 554.666667c0-209.066667 93.866667-362.666667 341.333333-277.333334 200.533333-87.466667 298.666667 100.266667 298.666667 277.333334z" fill="#9C27B0" /><path d="M701.866667 610.133333c4.266667-10.666667 4.266667-23.466667 4.266666-34.133333l4.266667-34.133333v-36.266667c0-12.8 0-23.466667-2.133333-34.133333s-4.266667-23.466667-6.4-34.133334c-2.133333-10.666667-6.4-21.333333-8.533334-32-6.4-21.333333-14.933333-40.533333-23.466666-61.866666-10.666667-17.066667-19.2-36.266667-29.866667-51.2-8.533333-12.8-17.066667-25.6-27.733333-36.266667-25.6 2.133333-51.2 8.533333-81.066667 21.333333 110.933333 85.333333 134.4 253.866667 134.4 264.533334v32c0 10.666667 0 21.333333-2.133333 32s-2.133333 21.333333-4.266667 32c0 10.666667-2.133333 21.333333-4.266667 29.866666-4.266667 19.2-8.533333 38.4-14.933333 57.6-6.4 17.066667-12.8 34.133333-19.2 49.066667-2.133333 8.533333-6.4 14.933333-8.533333 21.333333-4.266667 6.4-6.4 12.8-10.666667 19.2-6.4 12.8-10.666667 23.466667-14.933333 32-6.4 17.066667-10.666667 27.733333-10.666667 27.733334s6.4-8.533333 17.066667-25.6c6.4-8.533333 12.8-17.066667 19.2-29.866667 4.266667-6.4 8.533333-12.8 12.8-17.066667 4.266667-6.4 6.4-12.8 10.666666-21.333333 8.533333-14.933333 17.066667-32 25.6-49.066667 8.533333-17.066667 14.933333-38.4 21.333334-57.6 4.266667-10.666667 6.4-21.333333 8.533333-32 4.266667-10.666667 8.533333-21.333333 10.666667-32z" fill="#4A148C" /><path d="M533.333333 277.333333S505.6 10.666667 234.666667 106.666667c29.866667 277.333333 298.666667 170.666667 298.666666 170.666666z" fill="#4CAF50" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
3
packages/client/assets/svg/produce/potato.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M422.4 917.333333c-14.933333 0-29.866667 0-44.8-2.133333-98.133333-10.666667-179.2-57.6-224-132.266667-108.8-177.066667 2.133333-277.333333 108.8-371.2 27.733333-25.6 57.6-51.2 83.2-78.933333 8.533333-8.533333 17.066667-19.2 25.6-27.733333 115.2-128 243.2-273.066667 422.4-149.333334 72.533333 51.2 115.2 128 125.866667 224 12.8 132.266667-42.666667 283.733333-142.933334 388.266667-98.133333 93.866667-228.266667 149.333333-354.133333 149.333333z" fill="#8D6E63" /><path d="M422.4 917.333333c-14.933333 0-29.866667 0-44.8-2.133333-98.133333-10.666667-179.2-57.6-224-132.266667-29.866667-49.066667-42.666667-89.6-44.8-128 206.933333 187.733333 716.8 46.933333 808.533333-277.333333 12.8 132.266667-42.666667 283.733333-142.933333 388.266667-96 96-226.133333 151.466667-352 151.466666z" fill="#795548" /><path d="M480 821.333333m-32 0a32 32 0 1 0 64 0 32 32 0 1 0-64 0Z" fill="#3E2723" /><path d="M725.333333 277.333333c12.8 0 21.333333 8.533333 21.333334 21.333334s-8.533333 21.333333-21.333334 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z m0 234.666667c0 12.8 8.533333 21.333333 21.333334 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333333-21.333333 8.533333-21.333334 21.333333z m-170.666666-149.333333c0 12.8 8.533333 21.333333 21.333333 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333334-21.333333 8.533333-21.333333 21.333334zM298.666667 682.666667c0 12.8 8.533333 21.333333 21.333333 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333334-21.333333 8.533333-21.333333 21.333334z m128-74.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32-14.933333-32-32-32-32 14.933333-32 32z" fill="#5D4037" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
3
packages/client/assets/svg/produce/radish.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M524.8 605.866667s-19.2-27.733333-38.4-46.933334c-19.2-19.2-44.8-36.266667-46.933333-38.4-110.933333-83.2-206.933333-53.333333-256 0-38.4 42.666667-55.466667 85.333333-55.466667 128 0 32 12.8 64 17.066667 93.866667 12.8 59.733333 17.066667 98.133333-6.4 132.266667l-25.6 25.6c-8.533333 8.533333-8.533333 21.333333 0 29.866666 4.266667 6.4 8.533333 8.533333 14.933333 8.533334s10.666667-2.133333 14.933333-6.4l34.133334-34.133334c27.733333-23.466667 72.533333-12.8 123.733333-2.133333 34.133333 6.4 70.4 21.333333 108.8 21.333333 40.533333 0 83.2-19.2 119.466667-53.333333 55.466667-51.2 70.4-162.133333-4.266667-258.133333z" fill="#FF4081" /><path d="M531.2 701.866667c12.8-12.8 21.333333-29.866667 23.466667-49.066667-6.4-14.933333-17.066667-29.866667-27.733334-46.933333 0 0-19.2-27.733333-38.4-46.933334-19.2-19.2-44.8-36.266667-46.933333-38.4-17.066667-12.8-32-21.333333-46.933333-29.866666-19.2 2.133333-36.266667 8.533333-49.066667 21.333333-38.4 38.4-27.733333 113.066667 23.466667 164.266667s123.733333 64 162.133333 25.6z" fill="#F8BBD0" /><path d="M640 426.666667s100.266667-85.333333 174.933333-85.333334c49.066667 0 102.4 32 102.4 89.6 0 46.933333-12.8 102.4-70.4 102.4-55.466667 0-55.466667-42.666667-59.733333-59.733333-12.8 14.933333-72.533333 34.133333-89.6-14.933333 0 0-91.733333 27.733333-157.866667 76.8L640 426.666667z m-46.933333 21.333333c66.133333-76.8 10.666667-256-89.6-256C426.666667 192 405.333333 230.4 405.333333 264.533333c0 29.866667 29.866667 66.133333 72.533334 59.733334-8.533333 53.333333 40.533333 76.8 76.8 49.066666 21.333333 6.4-4.266667 121.6-61.866667 174.933334l100.266667-100.266667z" fill="#4CAF50" /><path d="M706.133333 85.333333C659.2 85.333333 618.666667 110.933333 618.666667 149.333333c0 51.2 57.6 44.8 57.6 44.8s-49.066667 59.733333 36.266666 76.8c-4.266667 21.333333-36.266667 61.866667-81.066666 110.933334l-198.4 198.4c-8.533333 8.533333-8.533333 21.333333 0 29.866666 4.266667 4.266667 10.666667 6.4 14.933333 6.4s10.666667-2.133333 14.933333-6.4l53.333334-53.333333c46.933333-42.666667 104.533333-102.4 172.8-172.8 0 0 61.866667-64 76.8-81.066667C870.4 198.4 853.333333 85.333333 706.133333 85.333333z" fill="#388E3C" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
3
packages/client/assets/svg/produce/raspberry.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
3
packages/client/assets/svg/produce/strawberry.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M806.677333 337.322667c-60.010667 60.010667-165.034667 45.013333-165.034666 45.013333s-14.954667-105.045333 45.013333-165.013333C746.666667 157.333333 851.669333 172.330667 851.669333 172.330667s15.018667 104.96-44.992 164.992z" fill="#689F38" /><path d="M651.285333 410.069333C787.072 425.152 937.92 576 937.92 576s15.082667-105.6-90.517333-181.013333c-105.6-75.434667-196.117333 15.082667-196.117334 15.082666zM616.234667 372.714667c-15.082667-135.765333-165.930667-286.634667-165.930667-286.634667s105.6-15.082667 181.013333 90.496c75.413333 105.621333-15.082667 196.138667-15.082666 196.138667z" fill="#8BC34A" /><path d="M750.997333 755.2c-114.517333 114.56-441.173333 234.837333-568.448 107.562667-127.296-127.274667-7.018667-453.909333 107.541334-568.448 114.56-114.56 256.832-50.453333 384.106666 76.842666 127.253333 127.232 191.36 269.482667 76.8 384.042667z" fill="#FF1744" /><path d="M346.282667 324.949333a53.312 53.312 0 1 1 75.413333 75.413334c-20.842667 20.821333-59.52 25.813333-80.362667 4.970666s-15.872-59.541333 4.949334-80.384zM521.856 448.96a53.312 53.312 0 1 1 75.413333 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.541333 4.970667-80.362667zM244.181333 725.738667a53.312 53.312 0 1 1 75.413334 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949333s-15.850667-59.541333 4.970666-80.362666zM646.314667 622.272a53.312 53.312 0 1 1 75.413333 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.52 4.970667-80.362667zM286.912 512.341333a53.312 53.312 0 1 1 75.413333 75.413334c-20.842667 20.821333-59.541333 25.792-80.384 4.949333s-15.850667-59.541333 4.970667-80.362667zM432.981333 686.272a53.312 53.312 0 1 1 75.413334 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.52 4.970666-80.362667z" fill="#D50000" /><path d="M399.082667 377.749333c-8.341333 8.341333-49.408 35.925333-57.749334 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165334 30.165333zM574.656 501.76c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM296.981333 778.517333c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM699.114667 675.072c-8.341333 8.341333-49.408 35.925333-57.749334 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165334 30.165333zM339.712 565.12c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM485.781333 739.072c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333z" fill="#FFC107" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
3
packages/client/assets/svg/produce/tomato.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M488.938667 242.368S135.445333 171.114667 90.389333 461.994667C45.418667 752.917333 312.576 843.968 389.76 861.397333c77.141333 17.408 423.808 65.322667 496.533333-249.898666 60.288-261.205333-172.309333-418.581333-371.605333-363.285334l-25.749333-5.845333z" fill="#FF3D00" /><path d="M514.688 248.213333l-25.749333-5.824S231.168 190.592 127.872 356.693333c34.048 20.778667 89.877333 45.162667 89.877333 45.162667 38.485333 16.597333 75.477333 25.045333 109.952 25.045333 34.048 0 65.237333-8.085333 92.373334-23.424 8.938667 43.157333 33.6 88.554667 91.008 123.626667l41.706666 25.472 19.626667-44.778667c16.448-37.546667 24.64-68.842667 26.645333-97.002666 22.08 11.114667 46.656 18.218667 73.493334 18.218666 32.192 0 64.746667-10.176 96.746666-30.186666 0 0 42.005333-24.256 68.928-44.757334-73.194667-95.850667-204.288-138.944-323.541333-105.856z" fill="#DD2C00" /><path d="M533.333333 490.688c40.896-93.333333 23.658667-135.701333-16.192-213.354667 54.528 71.04 127.296 149.354667 229.525334 85.354667-77.781333-20.672-63.701333-80.469333-160.298667-97.429333 72.448 3.029333 110.762667 25.685333 160.298667-73.237334-77.994667 41.045333-123.562667-48.896-224.917334 43.776-17.109333 10.624-35.072-16.341333-48.789333-1.728-73.173333-121.621333-160.128-12.928-195.626667-106.048 0 143.189333 137.429333 104.234667 170.666667 128-129.365333-10.901333-119.402667 87.552-213.333333 106.666667 123.562667 53.333333 203.754667 1.344 236.202666-87.274667-5.077333 34.624-48.768 147.328 62.464 215.274667z" fill="#4CAF50" /><path d="M469.354667 257.28c-0.554667 4.202667 8.362667 20.053333 37.290666 20.053333 34.624 0 21.354667-17.258667 21.354667-17.258666s-3.178667-99.626667 69.333333-165.461334c-18.410667-15.253333-37.781333-7.082667-37.781333-7.082666S482.133333 167.466667 469.354667 257.28z" fill="#4CAF50" /></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
26
packages/client/index.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tom's Allotment</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Rubik+Dirt&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400..700;1,400..700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3192
packages/client/package-lock.json
generated
Normal file
32
packages/client/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@toms-allotment/client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.29.3",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"lottie-react": "^2.4.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"react-responsive": "^10.0.1",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"simplebar-react": "^3.3.2",
|
||||||
|
"socket.io-client": "^4.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"vite": "^5.4.10",
|
||||||
|
"vite-plugin-svgo": "^2.0.0",
|
||||||
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
219
packages/client/src/App.css
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
:root {
|
||||||
|
/* Mirrors .sidebar width: 25% of viewport with 420px minimum */
|
||||||
|
--sidebar-width: max(25vw, 380px);
|
||||||
|
--app-main-padding-top: 18vh;
|
||||||
|
--app-main-padding-bottom: 18vh;
|
||||||
|
--app-main-padding-right: 10vw;
|
||||||
|
--app-main-padding-left: 10vw;
|
||||||
|
--bg: #0f1419;
|
||||||
|
--surface: #1a222c;
|
||||||
|
--border: #2a3544;
|
||||||
|
/* Same language as .btn default border (Button.css) */
|
||||||
|
--border-ui-width: 2px;
|
||||||
|
--border-ui-color: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
--danger-border-color: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
--text: #e8eef5;
|
||||||
|
--muted: color-mix(in srgb, var(--text) 75%, transparent);
|
||||||
|
--accent: #9cc815;
|
||||||
|
--accent-dim: #1fa86a;
|
||||||
|
--danger: #e56868;
|
||||||
|
--bg-spot: #1a2838;
|
||||||
|
--sidebar-bg: #0c1218;
|
||||||
|
--link-hover: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
--on-accent: #0a120e;
|
||||||
|
--warn: #e6b35c;
|
||||||
|
--font: "Rubik", system-ui, sans-serif;
|
||||||
|
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-inner {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
transform-origin: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--mobile .app-shell-inner {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-section.wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.ok {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.bad {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics.metrics--horizontal > * {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics .label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions li {
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated {
|
||||||
|
transition:
|
||||||
|
transform 0.35s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
opacity 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated.hidden {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
153
packages/client/src/App.jsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
BrowserRouter,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import AnimatedOutlet from "./components/AnimatedOutlet.jsx";
|
||||||
|
import { PageScroll } from "./components/PageScroll.jsx";
|
||||||
|
import { Sidebar } from "./components/Sidebar.jsx";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext.jsx";
|
||||||
|
import { ApiProvider } from "./contexts/ApiContext.jsx";
|
||||||
|
import { LottieFileProvider } from "./contexts/LottieFileContext.jsx";
|
||||||
|
import { ThemeProvider, useTheme } from "./contexts/ThemeContext.jsx";
|
||||||
|
import { PatchesPage } from "./pages/PatchesPage.jsx";
|
||||||
|
import { PowerPage } from "./pages/PowerPage.jsx";
|
||||||
|
import { WaterTankPage } from "./pages/WaterTankPage.jsx";
|
||||||
|
import { WateringSessionPage } from "./pages/WateringSessionPage.jsx";
|
||||||
|
import { WaterSchedulesPage } from "./pages/WaterSchedulesPage.jsx";
|
||||||
|
|
||||||
|
const SITE_TITLE = "Tom's Allotment";
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{
|
||||||
|
path: "/patches",
|
||||||
|
element: <PatchesPage />,
|
||||||
|
title: "Patches",
|
||||||
|
scrollable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/water-tank",
|
||||||
|
element: <WaterTankPage />,
|
||||||
|
title: "Water tank",
|
||||||
|
scrollable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/power",
|
||||||
|
element: <PowerPage />,
|
||||||
|
title: "Power",
|
||||||
|
scrollable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/watering-sessions",
|
||||||
|
element: <WateringSessionPage />,
|
||||||
|
title: "Watering sessions",
|
||||||
|
scrollable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/water-schedules",
|
||||||
|
element: <WaterSchedulesPage />,
|
||||||
|
title: "Watering schedules",
|
||||||
|
scrollable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const REDIRECT_ROUTES = [
|
||||||
|
{ path: "/", to: "/patches" },
|
||||||
|
{ path: "/overview", to: "/patches" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizePathname(pathname) {
|
||||||
|
if (pathname.length > 1 && pathname.endsWith("/")) {
|
||||||
|
return pathname.slice(0, -1);
|
||||||
|
}
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeMetaForPathname(pathname) {
|
||||||
|
const key = normalizePathname(pathname);
|
||||||
|
const route = ROUTES.find((r) => normalizePathname(r.path) === key);
|
||||||
|
return route
|
||||||
|
? { title: route.title, scrollable: route.scrollable }
|
||||||
|
: { title: SITE_TITLE, scrollable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const { isMobile, pageTransitionDuration } = useTheme();
|
||||||
|
const location = useLocation();
|
||||||
|
const { title: pageTitle, scrollable: routeScrollable } = useMemo(
|
||||||
|
() => routeMetaForPathname(location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [scrollable, setScrollable] = useState(routeScrollable);
|
||||||
|
const prevPathnameRef = useRef(location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPathnameRef.current === location.pathname) return;
|
||||||
|
|
||||||
|
prevPathnameRef.current = location.pathname;
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
setScrollable(routeScrollable);
|
||||||
|
}, pageTransitionDuration);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(id);
|
||||||
|
};
|
||||||
|
}, [location.pathname, routeScrollable, pageTransitionDuration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title =
|
||||||
|
pageTitle === SITE_TITLE ? SITE_TITLE : `${pageTitle} · ${SITE_TITLE}`;
|
||||||
|
}, [pageTitle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell ${isMobile ? "app-shell--mobile" : ""}`}>
|
||||||
|
<div className="app-shell-inner">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="app-main">
|
||||||
|
<PageScroll title={pageTitle} scrollable={scrollable}>
|
||||||
|
<AnimatedOutlet />
|
||||||
|
</PageScroll>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<ApiProvider>
|
||||||
|
<LottieFileProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
{REDIRECT_ROUTES.map(({ path, to }) => (
|
||||||
|
<Route
|
||||||
|
key={path}
|
||||||
|
path={path}
|
||||||
|
element={<Navigate to={to} replace />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{ROUTES.map(({ path, element }) => (
|
||||||
|
<Route key={path} path={path} element={element} />
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</LottieFileProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
packages/client/src/components/AnimatedOutlet.css
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
.animated-outlet-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-outlet {
|
||||||
|
width: 100%;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stable view: stays in document flow so scroll height matches content */
|
||||||
|
.animated-outlet.visible {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New route: overlays while old still defines block height */
|
||||||
|
.animated-outlet.enter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
animation: animatedOutletEnter var(--page-transition-duration, 500ms) ease
|
||||||
|
forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outgoing route: same flow as visible; motion matches AnimatedText exit */
|
||||||
|
.animated-outlet.exit {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
animation: animatedOutletExit var(--page-transition-duration, 500ms) ease
|
||||||
|
forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animatedOutletEnter {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animatedOutletExit {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back / forward: enter from top, exit downward */
|
||||||
|
.animated-outlet.enter.reverse {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
animation: animatedOutletEnterReverse var(--page-transition-duration, 500ms)
|
||||||
|
ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-outlet.exit.reverse {
|
||||||
|
animation: animatedOutletExitReverse var(--page-transition-duration, 500ms)
|
||||||
|
ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animatedOutletEnterReverse {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animatedOutletExitReverse {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
packages/client/src/components/AnimatedOutlet.jsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
} from "react";
|
||||||
|
import { useOutlet, useLocation, useNavigationType } from "react-router-dom";
|
||||||
|
import { useTheme } from "../contexts/ThemeContext.jsx";
|
||||||
|
import "./AnimatedOutlet.css";
|
||||||
|
|
||||||
|
function AnimatedOutlet() {
|
||||||
|
const { pageTransitionDuration } = useTheme();
|
||||||
|
const location = useLocation();
|
||||||
|
const outlet = useOutlet();
|
||||||
|
const navigationType = useNavigationType();
|
||||||
|
const [outlet1, setOutlet1] = useState(outlet);
|
||||||
|
const [outlet2, setOutlet2] = useState(null);
|
||||||
|
const [activeOutlet, setActiveOutlet] = useState(1);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
const [isReversed, setIsReversed] = useState(false);
|
||||||
|
const timeoutRef = useRef(null);
|
||||||
|
const prevPathnameRef = useRef(location.pathname);
|
||||||
|
const isTransitioningRef = useRef(false);
|
||||||
|
const activeOutletRef = useRef(1);
|
||||||
|
/** Latest route to animate to after the in-flight transition finishes (coalesces rapid navigations). */
|
||||||
|
const pendingNavigationRef = useRef(null);
|
||||||
|
const pageTransitionDurationRef = useRef(pageTransitionDuration);
|
||||||
|
pageTransitionDurationRef.current = pageTransitionDuration;
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const outlet1Ref = useRef(null);
|
||||||
|
const outlet2Ref = useRef(null);
|
||||||
|
|
||||||
|
activeOutletRef.current = activeOutlet;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!isTransitioning) {
|
||||||
|
container.style.minHeight = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitEl = activeOutlet === 1 ? outlet1Ref.current : outlet2Ref.current;
|
||||||
|
const enterEl = activeOutlet === 1 ? outlet2Ref.current : outlet1Ref.current;
|
||||||
|
if (!exitEl || !enterEl) return;
|
||||||
|
|
||||||
|
const hOld = exitEl.scrollHeight;
|
||||||
|
const hNew = enterEl.scrollHeight;
|
||||||
|
|
||||||
|
if (hNew > hOld) {
|
||||||
|
container.style.minHeight = `${hNew}px`;
|
||||||
|
} else {
|
||||||
|
container.style.minHeight = "";
|
||||||
|
}
|
||||||
|
}, [isTransitioning, activeOutlet, outlet1, outlet2]);
|
||||||
|
|
||||||
|
const completeTransition = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = activeOutletRef.current;
|
||||||
|
const nextActive = current === 1 ? 2 : 1;
|
||||||
|
if (current === 1) {
|
||||||
|
setOutlet1(null);
|
||||||
|
setActiveOutlet(2);
|
||||||
|
} else {
|
||||||
|
setOutlet2(null);
|
||||||
|
setActiveOutlet(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = pendingNavigationRef.current;
|
||||||
|
const shouldChain =
|
||||||
|
pending && pending.pathname !== prevPathnameRef.current;
|
||||||
|
|
||||||
|
if (shouldChain) {
|
||||||
|
pendingNavigationRef.current = null;
|
||||||
|
prevPathnameRef.current = pending.pathname;
|
||||||
|
setIsReversed(pending.navigationType === "POP");
|
||||||
|
if (nextActive === 1) {
|
||||||
|
setOutlet2(pending.outlet);
|
||||||
|
} else {
|
||||||
|
setOutlet1(pending.outlet);
|
||||||
|
}
|
||||||
|
isTransitioningRef.current = true;
|
||||||
|
setIsTransitioning(true);
|
||||||
|
timeoutRef.current = setTimeout(
|
||||||
|
completeTransition,
|
||||||
|
pageTransitionDurationRef.current,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
isTransitioningRef.current = false;
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== prevPathnameRef.current && outlet) {
|
||||||
|
if (isTransitioningRef.current) {
|
||||||
|
pendingNavigationRef.current = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
outlet,
|
||||||
|
navigationType,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBackward = navigationType === "POP";
|
||||||
|
setIsReversed(isBackward);
|
||||||
|
|
||||||
|
isTransitioningRef.current = true;
|
||||||
|
setIsTransitioning(true);
|
||||||
|
prevPathnameRef.current = location.pathname;
|
||||||
|
|
||||||
|
const current = activeOutletRef.current;
|
||||||
|
if (current === 1) {
|
||||||
|
setOutlet2(outlet);
|
||||||
|
} else {
|
||||||
|
setOutlet1(outlet);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(
|
||||||
|
completeTransition,
|
||||||
|
pageTransitionDurationRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [location.pathname, outlet, navigationType]);
|
||||||
|
|
||||||
|
const getOutlet1Class = () => {
|
||||||
|
if (!outlet1) return "";
|
||||||
|
const baseClass = isReversed ? "reverse" : "";
|
||||||
|
if (isTransitioning && activeOutlet === 1)
|
||||||
|
return `exit ${baseClass}`.trim();
|
||||||
|
if (isTransitioning && activeOutlet === 2)
|
||||||
|
return `enter ${baseClass}`.trim();
|
||||||
|
if (activeOutlet === 1) return "visible";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOutlet2Class = () => {
|
||||||
|
if (!outlet2) return "";
|
||||||
|
const baseClass = isReversed ? "reverse" : "";
|
||||||
|
if (isTransitioning && activeOutlet === 2)
|
||||||
|
return `exit ${baseClass}`.trim();
|
||||||
|
if (isTransitioning && activeOutlet === 1)
|
||||||
|
return `enter ${baseClass}`.trim();
|
||||||
|
if (activeOutlet === 2) return "visible";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="animated-outlet-container">
|
||||||
|
{outlet1 && (
|
||||||
|
<div
|
||||||
|
ref={outlet1Ref}
|
||||||
|
className={`animated-outlet ${getOutlet1Class()}`}
|
||||||
|
>
|
||||||
|
{outlet1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outlet2 && (
|
||||||
|
<div
|
||||||
|
ref={outlet2Ref}
|
||||||
|
className={`animated-outlet ${getOutlet2Class()}`}
|
||||||
|
>
|
||||||
|
{outlet2}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedOutlet;
|
||||||
46
packages/client/src/components/AnimatedText.css
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
.animated-text-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-current {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0s;
|
||||||
|
transform: translateY(0);
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-exiting {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-exiting-visible {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-entering {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-text-entering-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
198
packages/client/src/components/AnimatedText.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import "./AnimatedText.css";
|
||||||
|
|
||||||
|
function AnimatedText({
|
||||||
|
text,
|
||||||
|
transitionDuration = 500,
|
||||||
|
transitionDelay = 0,
|
||||||
|
style = {},
|
||||||
|
animateWidth = true,
|
||||||
|
animateEnterenceExitWidth = true,
|
||||||
|
}) {
|
||||||
|
const [enteringText, setEnteringText] = useState("");
|
||||||
|
const [currentText, setCurrentText] = useState(text);
|
||||||
|
const [exitingText, setExitingText] = useState("");
|
||||||
|
const [isEntering, setIsEntering] = useState(false);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const delay = 10;
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
const isTransitioningRef = useRef(false);
|
||||||
|
const prevTextRef = useRef(text);
|
||||||
|
const currentTextRef = useRef(currentText);
|
||||||
|
const textRef = useRef(text);
|
||||||
|
const timeoutRefs = useRef([]);
|
||||||
|
const enteringTextRef = useRef(null);
|
||||||
|
const [textWidth, setTextWidth] = useState(0);
|
||||||
|
const initialLoad = useRef(true);
|
||||||
|
|
||||||
|
// Helper function to clear all timeouts
|
||||||
|
const clearAllTimeouts = () => {
|
||||||
|
timeoutRefs.current.forEach((timeoutId) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timeoutRefs.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevText = prevTextRef.current;
|
||||||
|
|
||||||
|
// Check if text changed
|
||||||
|
const textChanged = prevText != text;
|
||||||
|
|
||||||
|
// Only trigger transition if text actually changed
|
||||||
|
if (textChanged) {
|
||||||
|
// Cancel any in-progress transition
|
||||||
|
clearAllTimeouts();
|
||||||
|
|
||||||
|
// Reset transition states immediately
|
||||||
|
setIsExiting(false);
|
||||||
|
setIsEntering(false);
|
||||||
|
setExitingText("");
|
||||||
|
setEnteringText("");
|
||||||
|
|
||||||
|
// Only apply entrance delay when transitioning from "" to text
|
||||||
|
const entranceOnly = prevText === "" && text !== "";
|
||||||
|
const effectiveDelay = entranceOnly ? transitionDelay : 0;
|
||||||
|
|
||||||
|
// Get the actual current text (from ref to avoid stale state)
|
||||||
|
const actualCurrentText = currentTextRef.current || prevText;
|
||||||
|
|
||||||
|
// Set exiting text to current text (starts visible)
|
||||||
|
setExitingText(actualCurrentText);
|
||||||
|
|
||||||
|
// Update current text immediately to the new value
|
||||||
|
setCurrentText(text);
|
||||||
|
currentTextRef.current = text;
|
||||||
|
|
||||||
|
// Set entering text to new text
|
||||||
|
setEnteringText(text);
|
||||||
|
|
||||||
|
setIsTransitioning(true);
|
||||||
|
isTransitioningRef.current = true;
|
||||||
|
|
||||||
|
// Start exit animation (no delay - exit is immediate)
|
||||||
|
const exitTimeout = setTimeout(() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
}, delay);
|
||||||
|
timeoutRefs.current.push(exitTimeout);
|
||||||
|
|
||||||
|
// Start enter animation (with delay only for entrance: "" to text)
|
||||||
|
const enterTimeout = setTimeout(() => {
|
||||||
|
setIsEntering(true);
|
||||||
|
}, delay + effectiveDelay);
|
||||||
|
timeoutRefs.current.push(enterTimeout);
|
||||||
|
|
||||||
|
// Clear exiting text after transition completes
|
||||||
|
const cleanupTimeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
// When animateEnterenceExitWidth is false, set width after effect has finished (before clearing ref)
|
||||||
|
if (!animateEnterenceExitWidth && enteringTextRef.current) {
|
||||||
|
setTextWidth(enteringTextRef.current.clientWidth);
|
||||||
|
}
|
||||||
|
setIsExiting(false);
|
||||||
|
setIsEntering(false);
|
||||||
|
setExitingText("");
|
||||||
|
setEnteringText("");
|
||||||
|
setIsTransitioning(false);
|
||||||
|
isTransitioningRef.current = false;
|
||||||
|
},
|
||||||
|
transitionDuration + effectiveDelay + delay,
|
||||||
|
);
|
||||||
|
timeoutRefs.current.push(cleanupTimeout);
|
||||||
|
|
||||||
|
prevTextRef.current = text;
|
||||||
|
} else {
|
||||||
|
// Update text without transition for updates without change
|
||||||
|
currentTextRef.current = text;
|
||||||
|
setEnteringText("");
|
||||||
|
setExitingText("");
|
||||||
|
setIsEntering(false);
|
||||||
|
setIsExiting(false);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
isTransitioningRef.current = false;
|
||||||
|
setCurrentText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
clearAllTimeouts();
|
||||||
|
};
|
||||||
|
}, [text, transitionDuration, transitionDelay, animateEnterenceExitWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enteringTextRef.current) {
|
||||||
|
if (animateWidth && isTransitioning) {
|
||||||
|
setTextWidth(enteringTextRef.current.clientWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!animateWidth || !animateEnterenceExitWidth) &&
|
||||||
|
!isTransitioning &&
|
||||||
|
textRef.current
|
||||||
|
) {
|
||||||
|
setTextWidth(textRef.current.clientWidth);
|
||||||
|
}
|
||||||
|
if (initialLoad.current == true && textRef.current) {
|
||||||
|
initialLoad.current = false;
|
||||||
|
setTextWidth(textRef.current.clientWidth);
|
||||||
|
}
|
||||||
|
}, [enteringText, animateWidth, animateEnterenceExitWidth, isTransitioning]);
|
||||||
|
|
||||||
|
// Keep currentTextRef in sync
|
||||||
|
useEffect(() => {
|
||||||
|
currentTextRef.current = currentText;
|
||||||
|
}, [currentText]);
|
||||||
|
|
||||||
|
const transitionStyle = {
|
||||||
|
transition: `opacity ${transitionDuration}ms ease, transform ${transitionDuration}ms ease`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animated-text-container"
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
width: textWidth,
|
||||||
|
transition:
|
||||||
|
animateWidth && animateEnterenceExitWidth
|
||||||
|
? `width ${transitionDuration}ms ease`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`animated-text animated-text-current ${
|
||||||
|
isTransitioning == true ? "animated-text-hidden" : ""
|
||||||
|
}`}
|
||||||
|
ref={textRef}
|
||||||
|
>
|
||||||
|
{currentText}
|
||||||
|
</div>
|
||||||
|
{exitingText && (
|
||||||
|
<div
|
||||||
|
className={`animated-text animated-text-exiting ${
|
||||||
|
isExiting == true ? "animated-text-exiting-visible" : ""
|
||||||
|
}`}
|
||||||
|
style={transitionStyle}
|
||||||
|
>
|
||||||
|
{exitingText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enteringText && (
|
||||||
|
<div
|
||||||
|
className={`animated-text animated-text-entering ${
|
||||||
|
isEntering == true ? "animated-text-entering-visible" : ""
|
||||||
|
}`}
|
||||||
|
style={transitionStyle}
|
||||||
|
ref={enteringTextRef}
|
||||||
|
>
|
||||||
|
{enteringText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedText;
|
||||||
125
packages/client/src/components/Button.css
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/* Matches tombutcher-ui .tb-button (components.css) — uses app :root --text / --bg */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
fill 0.3s ease,
|
||||||
|
stroke 0.3s ease,
|
||||||
|
transform 0.5s ease,
|
||||||
|
border-color 0.3s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
min-height: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
position: relative;
|
||||||
|
transform-origin: center center;
|
||||||
|
transform: scale(1);
|
||||||
|
width: max-content;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):hover {
|
||||||
|
background-color: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):active {
|
||||||
|
background-color: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
transform: scale(0.975);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn > svg {
|
||||||
|
fill: var(--text);
|
||||||
|
stroke: var(--text);
|
||||||
|
height: 16px;
|
||||||
|
width: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
fill 0.3s ease,
|
||||||
|
stroke 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):hover > svg {
|
||||||
|
fill: var(--bg);
|
||||||
|
stroke: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filled CTA — emphasis while keeping pill + motion language */
|
||||||
|
.btn--primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border: var(--border-ui-width) solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:not(:disabled):hover {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger:not(:disabled):hover {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: var(--on-warn);
|
||||||
|
border: var(--border-ui-width) solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:not(:disabled):active {
|
||||||
|
filter: brightness(0.98);
|
||||||
|
transform: scale(0.975);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary > svg {
|
||||||
|
fill: var(--on-accent);
|
||||||
|
stroke: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:not(:disabled):hover > svg,
|
||||||
|
.btn--primary:not(:disabled):active > svg {
|
||||||
|
fill: var(--on-accent);
|
||||||
|
stroke: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:disabled {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lighter outline; same hover invert as default */
|
||||||
|
.btn--ghost {
|
||||||
|
border-color: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--small {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 26px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--small > svg {
|
||||||
|
height: 14px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
47
packages/client/src/components/Button.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import "./Button.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {'default' | 'primary' | 'ghost'} [props.variant]
|
||||||
|
* @param {'middle' | 'small'} [props.size]
|
||||||
|
* @param {string} [props.href] — if set, renders `<a>`
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {import('react').ReactNode} props.children
|
||||||
|
*/
|
||||||
|
export function Button({
|
||||||
|
variant = "default",
|
||||||
|
size = "middle",
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
danger = false,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const classes = cx(
|
||||||
|
"btn",
|
||||||
|
variant === "primary" && "btn--primary",
|
||||||
|
variant === "ghost" && "btn--ghost",
|
||||||
|
size === "small" && "btn--small",
|
||||||
|
danger && "btn--danger",
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a href={href} className={classes} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type={type} className={classes} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
packages/client/src/components/DateTimeInput.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
.date-time-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input .popover-wrapper {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__trigger--readonly {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__field {
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-root.date-time-input__popover {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: min(320px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__panel {
|
||||||
|
padding: 4px 2px 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__month {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__nav {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day:hover:not(:disabled):not(
|
||||||
|
.date-time-input__day--disabled
|
||||||
|
) {
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day--outside {
|
||||||
|
color: color-mix(in srgb, var(--muted) 85%, var(--text));
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day--today {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day--selected {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day--selected:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent) 88%, var(--text));
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__day--disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 6px 4px;
|
||||||
|
border-top: 1px solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__time-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__time {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__time:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__time:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-time-input__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
406
packages/client/src/components/DateTimeInput.jsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
IoCalendarOutline,
|
||||||
|
IoChevronBack,
|
||||||
|
IoChevronForward,
|
||||||
|
IoCloseCircle,
|
||||||
|
} from "react-icons/io5";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { IconButton } from "./IconButton.jsx";
|
||||||
|
import { Popover } from "./Popover.jsx";
|
||||||
|
import "./Input.css";
|
||||||
|
import "./DateTimeInput.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass = {
|
||||||
|
small: "sm",
|
||||||
|
middle: "md",
|
||||||
|
large: "lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Day.js has no `dayjs.invalid()` (that is Moment.js API). */
|
||||||
|
const INVALID_DAYJS = dayjs(new Date(NaN));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} v
|
||||||
|
* @returns {import('dayjs').Dayjs}
|
||||||
|
*/
|
||||||
|
function toDayjs(v) {
|
||||||
|
if (v == null || v === "") return INVALID_DAYJS;
|
||||||
|
const d = dayjs(v);
|
||||||
|
return d.isValid() ? d : INVALID_DAYJS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–like datetime field: Input-styled trigger, calendar + time in Popover.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {Date | string | number | null} [props.value]
|
||||||
|
* @param {Date | string | number | null} [props.defaultValue]
|
||||||
|
* @param {(date: Date | null) => void} [props.onChange]
|
||||||
|
* @param {string} [props.format='YYYY-MM-DD HH:mm']
|
||||||
|
* @param {string} [props.placeholder]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} [props.readOnly]
|
||||||
|
* @param {boolean} [props.allowClear]
|
||||||
|
* @param {boolean} [props.bordered=true]
|
||||||
|
* @param {'large' | 'middle' | 'small'} [props.size='middle']
|
||||||
|
* @param {'' | 'error' | 'warning'} [props.status]
|
||||||
|
* @param {(d: import('dayjs').Dayjs) => boolean} [props.disabledDate]
|
||||||
|
* @param {boolean} [props.open]
|
||||||
|
* @param {(open: boolean) => void} [props.onOpenChange]
|
||||||
|
* @param {string} [props.placement='bottomLeft']
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {import('react').CSSProperties} [props.style]
|
||||||
|
* @param {string} [props.id]
|
||||||
|
* @param {string} [props.name]
|
||||||
|
* @param {string} [props.inputMode]
|
||||||
|
*/
|
||||||
|
export const DateTimeInput = forwardRef(function DateTimeInput(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
format = "YYYY-MM-DD HH:mm",
|
||||||
|
placeholder = "Select date and time",
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
allowClear = false,
|
||||||
|
bordered = true,
|
||||||
|
size = "middle",
|
||||||
|
status,
|
||||||
|
disabledDate,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
|
placement = "bottomLeft",
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
showTime = true,
|
||||||
|
inputMode = "none",
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const uid = useId();
|
||||||
|
const timeInputId = id ? `${id}-time` : `date-time-input-time-${uid}`;
|
||||||
|
const sz = sizeClass[size] ?? "md";
|
||||||
|
const innerRef = useRef(null);
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node) => {
|
||||||
|
innerRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [innerValue, setInnerValue] = useState(() =>
|
||||||
|
defaultValue != null ? toDayjs(defaultValue).toDate() : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isControlled && defaultValue !== undefined) {
|
||||||
|
const d = toDayjs(defaultValue);
|
||||||
|
setInnerValue(d.isValid() ? d.toDate() : null);
|
||||||
|
}
|
||||||
|
}, [defaultValue, isControlled]);
|
||||||
|
|
||||||
|
const resolvedDate = useMemo(() => {
|
||||||
|
const raw = isControlled ? value : innerValue;
|
||||||
|
const d = toDayjs(raw);
|
||||||
|
return d.isValid() ? d : null;
|
||||||
|
}, [isControlled, value, innerValue]);
|
||||||
|
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||||
|
const isOpenControlled = openProp !== undefined;
|
||||||
|
const open = isOpenControlled ? openProp : uncontrolledOpen;
|
||||||
|
|
||||||
|
const setOpen = useCallback(
|
||||||
|
(next) => {
|
||||||
|
if (!isOpenControlled) setUncontrolledOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
},
|
||||||
|
[isOpenControlled, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [viewMonth, setViewMonth] = useState(() =>
|
||||||
|
(resolvedDate ?? dayjs()).startOf("month"),
|
||||||
|
);
|
||||||
|
const [draft, setDraft] = useState(() => resolvedDate ?? dayjs());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const base = resolvedDate ?? dayjs();
|
||||||
|
setDraft(base);
|
||||||
|
setViewMonth(base.startOf("month"));
|
||||||
|
}, [open, resolvedDate]);
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(nextDayjs) => {
|
||||||
|
const next = nextDayjs.isValid() ? nextDayjs.toDate() : null;
|
||||||
|
if (!isControlled) setInnerValue(next);
|
||||||
|
onChange?.(next);
|
||||||
|
},
|
||||||
|
[isControlled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled || readOnly) return;
|
||||||
|
emitChange(INVALID_DAYJS);
|
||||||
|
setOpen(false);
|
||||||
|
innerRef.current?.focus?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const start = viewMonth.startOf("month").startOf("week");
|
||||||
|
const rows = [];
|
||||||
|
let cursor = start;
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
const row = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
row.push(cursor);
|
||||||
|
cursor = cursor.add(1, "day");
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [viewMonth]);
|
||||||
|
|
||||||
|
const weekdayLabels = useMemo(() => {
|
||||||
|
const start = dayjs().startOf("week");
|
||||||
|
return Array.from({ length: 7 }, (_, i) =>
|
||||||
|
start.add(i, "day").format("dd"),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayText = resolvedDate ? resolvedDate.format(format) : "";
|
||||||
|
|
||||||
|
const timeStr = draft.isValid() ? draft.format("HH:mm") : "00:00";
|
||||||
|
|
||||||
|
const statusMod =
|
||||||
|
status === "error" ? "error" : status === "warning" ? "warning" : null;
|
||||||
|
|
||||||
|
const showClear =
|
||||||
|
allowClear &&
|
||||||
|
!disabled &&
|
||||||
|
!readOnly &&
|
||||||
|
resolvedDate != null &&
|
||||||
|
resolvedDate.isValid();
|
||||||
|
|
||||||
|
const pickerDisabled = disabled || readOnly;
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<div
|
||||||
|
className="date-time-input__panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Choose date and time"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="date-time-input__head">
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
className="date-time-input__nav"
|
||||||
|
aria-label="Previous month"
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
onClick={() => setViewMonth((m) => m.subtract(1, "month"))}
|
||||||
|
>
|
||||||
|
<IoChevronBack aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
<span className="date-time-input__month">
|
||||||
|
{viewMonth.format("MMMM YYYY")}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
className="date-time-input__nav"
|
||||||
|
aria-label="Next month"
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
onClick={() => setViewMonth((m) => m.add(1, "month"))}
|
||||||
|
>
|
||||||
|
<IoChevronForward aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="date-time-input__weekdays" aria-hidden>
|
||||||
|
{weekdayLabels.map((l) => (
|
||||||
|
<span key={l} className="date-time-input__weekday">
|
||||||
|
{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="date-time-input__grid" role="grid">
|
||||||
|
{weeks.map((row, ri) => (
|
||||||
|
<div key={ri} className="date-time-input__row" role="row">
|
||||||
|
{row.map((cell) => {
|
||||||
|
const inMonth = cell.isSame(viewMonth, "month");
|
||||||
|
const selected = draft.isValid() && cell.isSame(draft, "day");
|
||||||
|
const isToday = cell.isSame(dayjs(), "day");
|
||||||
|
const cellDisabled = Boolean(disabledDate?.(cell));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.valueOf()}
|
||||||
|
type="button"
|
||||||
|
role="gridcell"
|
||||||
|
disabled={pickerDisabled || cellDisabled}
|
||||||
|
className={cx(
|
||||||
|
"date-time-input__day",
|
||||||
|
!inMonth && "date-time-input__day--outside",
|
||||||
|
selected && "date-time-input__day--selected",
|
||||||
|
isToday && "date-time-input__day--today",
|
||||||
|
cellDisabled && "date-time-input__day--disabled",
|
||||||
|
)}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => {
|
||||||
|
if (cellDisabled) return;
|
||||||
|
const next = cell
|
||||||
|
.hour(draft.isValid() ? draft.hour() : 0)
|
||||||
|
.minute(draft.isValid() ? draft.minute() : 0)
|
||||||
|
.second(0);
|
||||||
|
setDraft(next);
|
||||||
|
emitChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell.date()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showTime && (
|
||||||
|
<div className="date-time-input__time-row">
|
||||||
|
<label className="date-time-input__time-label" htmlFor={timeInputId}>
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={timeInputId}
|
||||||
|
type="time"
|
||||||
|
className="date-time-input__time"
|
||||||
|
step={60}
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
value={timeStr}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) return;
|
||||||
|
const [hh, mm] = v.split(":").map((x) => parseInt(x, 10));
|
||||||
|
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return;
|
||||||
|
const base = draft.isValid() ? draft : dayjs();
|
||||||
|
const next = base.hour(hh).minute(mm).second(0);
|
||||||
|
setDraft(next);
|
||||||
|
emitChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="date-time-input__footer">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
const t = dayjs();
|
||||||
|
setDraft(t);
|
||||||
|
setViewMonth(t.startOf("month"));
|
||||||
|
emitChange(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"input-affix-wrapper",
|
||||||
|
`input-affix-wrapper--${sz}`,
|
||||||
|
!bordered && "input--borderless",
|
||||||
|
disabled && "input-affix-wrapper--disabled",
|
||||||
|
statusMod && `input-affix-wrapper--${statusMod}`,
|
||||||
|
"date-time-input__trigger",
|
||||||
|
readOnly && "date-time-input__trigger--readonly",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={setRefs}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
className="input-affix-inner date-time-input__field"
|
||||||
|
readOnly
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-readonly={readOnly || undefined}
|
||||||
|
aria-disabled={readOnly && !disabled ? true : undefined}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={displayText}
|
||||||
|
inputMode={inputMode}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
autoComplete="off"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (pickerDisabled) return;
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<span className="input-suffix">
|
||||||
|
{showClear ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="input-clear"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Clear value"
|
||||||
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<IoCloseCircle size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<span className="date-time-input__icon" aria-hidden>
|
||||||
|
<IoCalendarOutline size={16} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="date-time-input">
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement={placement}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
content={panel}
|
||||||
|
overlayClassName="date-time-input__popover"
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DateTimeInput.displayName = "DateTimeInput";
|
||||||
458
packages/client/src/components/Drawer.css
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
/* Drawer panel + mask (Ant Design–style). Main content scale lives on .app-main. */
|
||||||
|
|
||||||
|
.drawer-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-root.drawer-root--active {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, #000 45%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-root.drawer-root--visible .drawer-mask {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--right {
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--left {
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--top {
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--bottom {
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--has-header-backdrop {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: height 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header-backdrop-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header-backdrop-blur {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
filter: blur(6px);
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: opacity 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: -12px 0 40px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
transition: transform 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--right .drawer-panel {
|
||||||
|
height: 100%;
|
||||||
|
border-right: none;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--left .drawer-panel {
|
||||||
|
height: 100%;
|
||||||
|
border-left: none;
|
||||||
|
box-shadow: 12px 0 40px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--top .drawer-panel {
|
||||||
|
width: 100%;
|
||||||
|
border-top: none;
|
||||||
|
box-shadow: 0 12px 40px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-wrap--bottom .drawer-panel {
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: none;
|
||||||
|
box-shadow: 0 -12px 40px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-root.drawer-root--visible .drawer-wrap--right .drawer-panel,
|
||||||
|
.drawer-root.drawer-root--visible .drawer-wrap--left .drawer-panel,
|
||||||
|
.drawer-root.drawer-root--visible .drawer-wrap--top .drawer-panel,
|
||||||
|
.drawer-root.drawer-root--visible .drawer-wrap--bottom .drawer-panel {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 4vw 4vw 0;
|
||||||
|
min-height: 52px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--has-header-backdrop .drawer-header {
|
||||||
|
padding-bottom: 4vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--mobile .drawer-header {
|
||||||
|
padding: var(--app-main-padding-left) var(--app-main-padding-right) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--has-header-backdrop.drawer-panel--mobile .drawer-header {
|
||||||
|
padding-bottom: var(--app-main-padding-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--has-header-backdrop .drawer-header .drawer-title,
|
||||||
|
.drawer-panel--has-header-backdrop .drawer-header .drawer-close {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 4vw max(4vw, env(safe-area-inset-bottom, 0px));
|
||||||
|
min-height: 52px;
|
||||||
|
z-index: 100;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--mobile .drawer-footer {
|
||||||
|
padding-left: var(--app-main-padding-left);
|
||||||
|
padding-right: var(--app-main-padding-right);
|
||||||
|
padding-bottom: var(--app-main-padding-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: "Libre Baskerville", serif;
|
||||||
|
text-shadow: 0 0 20px var(--surface);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close:hover {
|
||||||
|
background: var(--link-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-top-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
top: -24px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 50%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 50%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-top-gradient--has-header-backdrop {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255 / 0%) 0%,
|
||||||
|
rgb(255 255 255) 65%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255 / 0%) 0%,
|
||||||
|
rgb(255 255 255) 65%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
transition: top 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-backdrop-header-top-gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-bottom-gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: unset;
|
||||||
|
bottom: -30px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 25%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 25%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
mask: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
black var(--drawer-scroll-top-gradient-height, 50px),
|
||||||
|
black var(--drawer-scroll-bottom-gradient-height, 50px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
black var(--drawer-scroll-top-gradient-height, 50px),
|
||||||
|
black var(--drawer-scroll-bottom-gradient-height, 50px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll .simplebar-vertical {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
width: var(--border-ui-width);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
margin-top: var(--drawer-scroll-simplebar-track-margin-top, 0);
|
||||||
|
margin-bottom: var(--drawer-scroll-simplebar-track-margin-bottom, 0);
|
||||||
|
margin-right: calc(4vw / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
width: var(--border-ui-width);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll.simplebar-scrolling::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll.simplebar-scrolling .simplebar-vertical {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4vw;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-start {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: normal;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll .simplebar-scrollbar::before {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scroll-vscroll .simplebar-scrollbar.simplebar-visible,
|
||||||
|
.drawer-scroll-vscroll .simplebar-scrollbar.simplebar-visible::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--mobile .drawer-scroll-content {
|
||||||
|
padding: var(--app-main-padding-left) var(--app-main-padding-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel--mobile .drawer-scroll-vscroll .simplebar-vertical {
|
||||||
|
margin-right: calc(var(--app-main-padding-right) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-body--flush .drawer-scroll-content {
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-inner.app-shell--drawer-open {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel.drawer-panel--has-header-backdrop .drawer-footer {
|
||||||
|
bottom: unset;
|
||||||
|
color: #ffffff;
|
||||||
|
transition:
|
||||||
|
top 0.35s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
508
packages/client/src/components/Drawer.jsx
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { IoClose } from "react-icons/io5";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
import { useTheme } from "../contexts/ThemeContext.jsx";
|
||||||
|
import "./Drawer.css";
|
||||||
|
|
||||||
|
const DRAWER_SCROLL_GRADIENT_HEIGHT = 40;
|
||||||
|
const SCROLL_BACKDROP_HEIGHT_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
|
let appMainDrawerDepth = 0;
|
||||||
|
function syncAppMainDrawerClass() {
|
||||||
|
const shell = document.querySelector(".app-shell-inner");
|
||||||
|
if (!shell) return;
|
||||||
|
shell.classList.toggle("app-shell--drawer-open", appMainDrawerDepth > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushAppMainDrawer() {
|
||||||
|
appMainDrawerDepth += 1;
|
||||||
|
syncAppMainDrawerClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
function popAppMainDrawer() {
|
||||||
|
appMainDrawerDepth = Math.max(0, appMainDrawerDepth - 1);
|
||||||
|
syncAppMainDrawerClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITION_MS = 350;
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–style drawer: slides from an edge with a mask. While open, scales `.app-main`.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} props.open
|
||||||
|
* @param {() => void} props.onClose
|
||||||
|
* @param {import('react').ReactNode} [props.title]
|
||||||
|
* @param {'left' | 'right' | 'top' | 'bottom'} [props.placement]
|
||||||
|
* @param {number | string} [props.width] — right/left drawer width
|
||||||
|
* @param {number | string} [props.height] — top/bottom drawer height
|
||||||
|
* @param {string} [props.className] — on panel
|
||||||
|
* @param {boolean} [props.maskClosable]
|
||||||
|
* @param {boolean} [props.closable]
|
||||||
|
* @param {boolean} [props.destroyOnClose]
|
||||||
|
* @param {import('react').ReactNode} [props.footer] — pinned below scroll, like header above
|
||||||
|
* @param {import('react').ReactNode} [props.headerBackdrop] — behind title/close, height `headerBackdropHeight`, centered; scroll content gets `padding-top` of that height
|
||||||
|
* @param {number | string} [props.headerBackdropHeight]
|
||||||
|
* @param {import('react').ReactNode} props.children
|
||||||
|
*/
|
||||||
|
export function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
placement = "right",
|
||||||
|
width = 378,
|
||||||
|
height = 256,
|
||||||
|
className,
|
||||||
|
maskClosable = true,
|
||||||
|
closable = true,
|
||||||
|
destroyOnClose = false,
|
||||||
|
footer,
|
||||||
|
headerBackdrop = undefined,
|
||||||
|
showHeaderBackdrop = false,
|
||||||
|
headerBackdropHeight = 120,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useTheme();
|
||||||
|
const effectivePlacement = isMobile ? "bottom" : placement;
|
||||||
|
const [mounted, setMounted] = useState(open);
|
||||||
|
const [visible, setVisible] = useState(open);
|
||||||
|
const closeTimerRef = useRef(null);
|
||||||
|
const scrollContentRef = useRef(null);
|
||||||
|
const animatedRevealTimeoutsRef = useRef([]);
|
||||||
|
const headerMeasureRef = useRef(null);
|
||||||
|
const footerMeasureRef = useRef(null);
|
||||||
|
const [headerClientHeight, setHeaderClientHeight] = useState(0);
|
||||||
|
const [footerClientHeight, setFooterClientHeight] = useState(0);
|
||||||
|
const numericBackdrop = typeof headerBackdropHeight === "number";
|
||||||
|
const [scrolledBackdropHeaderHeight, setScrolledBackdropHeaderHeight] =
|
||||||
|
useState(() =>
|
||||||
|
typeof headerBackdropHeight === "number"
|
||||||
|
? headerBackdropHeight
|
||||||
|
: Number(headerBackdropHeight) || 120,
|
||||||
|
);
|
||||||
|
/** Footer opacity vs scroll: 1 at top → 0 over the same range as backdrop height collapse. */
|
||||||
|
const [footerScrollOpacity, setFooterScrollOpacity] = useState(1);
|
||||||
|
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericBackdrop) return;
|
||||||
|
setScrolledBackdropHeaderHeight(headerBackdropHeight);
|
||||||
|
}, [headerBackdropHeight, numericBackdrop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !showHeaderBackdrop) return;
|
||||||
|
let active = true;
|
||||||
|
let removeListener = () => {};
|
||||||
|
|
||||||
|
const maxH = headerBackdropHeight;
|
||||||
|
const minH = headerClientHeight;
|
||||||
|
const scrollRange =
|
||||||
|
minH > 0 && typeof maxH === "number" ? Math.max(0, maxH - minH) : 0;
|
||||||
|
|
||||||
|
const attach = () => {
|
||||||
|
const content = scrollContentRef.current;
|
||||||
|
const wrapper = content?.closest(".simplebar-content-wrapper");
|
||||||
|
if (!wrapper || !active) return;
|
||||||
|
|
||||||
|
let scrollDebounceTimer = 0;
|
||||||
|
/** @type {number | null} */
|
||||||
|
let queuedScrollTop = null;
|
||||||
|
|
||||||
|
/** Apply header backdrop height and footer opacity from scroll position `y`. */
|
||||||
|
const flushScrollDerivedUi = (y) => {
|
||||||
|
if (!numericBackdrop || scrollRange <= 0) {
|
||||||
|
setFooterScrollOpacity(1);
|
||||||
|
} else {
|
||||||
|
const t = Math.min(1, Math.max(0, y / scrollRange));
|
||||||
|
setFooterScrollOpacity(1 - t);
|
||||||
|
}
|
||||||
|
if (numericBackdrop) {
|
||||||
|
const next = Math.max(minH, maxH - Math.min(y, scrollRange));
|
||||||
|
setScrolledBackdropHeaderHeight(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleScrollDebounceTimeout = () => {
|
||||||
|
scrollDebounceTimer = window.setTimeout(() => {
|
||||||
|
scrollDebounceTimer = 0;
|
||||||
|
if (queuedScrollTop != null) {
|
||||||
|
const yq = queuedScrollTop;
|
||||||
|
queuedScrollTop = null;
|
||||||
|
flushScrollDerivedUi(yq);
|
||||||
|
scheduleScrollDebounceTimeout();
|
||||||
|
}
|
||||||
|
}, SCROLL_BACKDROP_HEIGHT_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const y = wrapper.scrollTop;
|
||||||
|
if (!numericBackdrop) {
|
||||||
|
flushScrollDerivedUi(y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scrollDebounceTimer === 0) {
|
||||||
|
flushScrollDerivedUi(y);
|
||||||
|
scheduleScrollDebounceTimeout();
|
||||||
|
} else {
|
||||||
|
queuedScrollTop = y;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
flushScrollDerivedUi(wrapper.scrollTop);
|
||||||
|
wrapper.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
removeListener = () => {
|
||||||
|
window.clearTimeout(scrollDebounceTimer);
|
||||||
|
scrollDebounceTimer = 0;
|
||||||
|
queuedScrollTop = null;
|
||||||
|
wrapper.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let innerRaf = 0;
|
||||||
|
const outerRaf = requestAnimationFrame(() => {
|
||||||
|
innerRaf = requestAnimationFrame(attach);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
cancelAnimationFrame(outerRaf);
|
||||||
|
cancelAnimationFrame(innerRaf);
|
||||||
|
removeListener();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
visible,
|
||||||
|
showHeaderBackdrop,
|
||||||
|
numericBackdrop,
|
||||||
|
headerBackdropHeight,
|
||||||
|
headerClientHeight,
|
||||||
|
children,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !showHeaderBackdrop) setFooterScrollOpacity(1);
|
||||||
|
}, [visible, showHeaderBackdrop]);
|
||||||
|
|
||||||
|
const measureHeaderFooter = useCallback(() => {
|
||||||
|
setHeaderClientHeight(headerMeasureRef.current?.clientHeight ?? 0);
|
||||||
|
setFooterClientHeight(footerMeasureRef.current?.clientHeight ?? 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
measureHeaderFooter();
|
||||||
|
const headerEl = headerMeasureRef.current;
|
||||||
|
const footerEl = footerMeasureRef.current;
|
||||||
|
const ro = new ResizeObserver(() => measureHeaderFooter());
|
||||||
|
if (headerEl) ro.observe(headerEl);
|
||||||
|
if (footerEl) ro.observe(footerEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [visible, title, closable, footer != null, measureHeaderFooter]);
|
||||||
|
|
||||||
|
const clearCloseTimer = useCallback(() => {
|
||||||
|
if (closeTimerRef.current != null) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
clearCloseTimer();
|
||||||
|
setMounted(true);
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
setTimeout(() => setVisible(true), 10),
|
||||||
|
);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
if (destroyOnClose) {
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
setMounted(false);
|
||||||
|
}, TRANSITION_MS);
|
||||||
|
}
|
||||||
|
return () => clearCloseTimer();
|
||||||
|
}, [open, destroyOnClose, clearCloseTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
pushAppMainDrawer();
|
||||||
|
return () => popAppMainDrawer();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const prev = document.body.style.backgroundColor;
|
||||||
|
document.body.style.backgroundColor = "var(--surface)";
|
||||||
|
return () => {
|
||||||
|
document.body.style.backgroundColor = prev;
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !onClose) return;
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [visible, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const clearAnimatedRevealTimers = () => {
|
||||||
|
animatedRevealTimeoutsRef.current.forEach((id) => clearTimeout(id));
|
||||||
|
animatedRevealTimeoutsRef.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripVisibleFromAnimated = () => {
|
||||||
|
const root = scrollContentRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const animated = document.getElementsByClassName("animated");
|
||||||
|
for (let i = 0; i < animated.length; i += 1) {
|
||||||
|
const el = animated[i];
|
||||||
|
if (root.contains(el)) el.classList.remove("visible");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startId = window.setTimeout(() => {
|
||||||
|
const root = scrollContentRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const allAnimated = document.getElementsByClassName("animated");
|
||||||
|
const inside = [];
|
||||||
|
for (let i = 0; i < allAnimated.length; i += 1) {
|
||||||
|
const el = allAnimated[i];
|
||||||
|
if (root.contains(el)) inside.push(el);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < inside.length; i += 1) {
|
||||||
|
const el = inside[i];
|
||||||
|
el.style.zIndex = inside.length - i;
|
||||||
|
}
|
||||||
|
inside.forEach((el, index) => {
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
el.classList.add("visible");
|
||||||
|
}, index * 100);
|
||||||
|
animatedRevealTimeoutsRef.current.push(id);
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(startId);
|
||||||
|
clearAnimatedRevealTimers();
|
||||||
|
stripVisibleFromAnimated();
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const onMaskClick = () => {
|
||||||
|
if (maskClosable) onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const w = typeof width === "number" ? `${width}px` : width;
|
||||||
|
const h = isMobile
|
||||||
|
? "95dvh"
|
||||||
|
: typeof height === "number"
|
||||||
|
? `${height}px`
|
||||||
|
: height;
|
||||||
|
const panelStyle =
|
||||||
|
effectivePlacement === "right" || effectivePlacement === "left"
|
||||||
|
? { width: w }
|
||||||
|
: { height: h };
|
||||||
|
|
||||||
|
const backdropH =
|
||||||
|
typeof headerBackdropHeight === "number"
|
||||||
|
? `${headerBackdropHeight}px`
|
||||||
|
: headerBackdropHeight;
|
||||||
|
|
||||||
|
const backdropDisplayH = numericBackdrop
|
||||||
|
? `${scrolledBackdropHeaderHeight}px`
|
||||||
|
: backdropH;
|
||||||
|
|
||||||
|
const backdropLayoutPx =
|
||||||
|
showHeaderBackdrop && numericBackdrop
|
||||||
|
? scrolledBackdropHeaderHeight
|
||||||
|
: typeof headerBackdropHeight === "number"
|
||||||
|
? headerBackdropHeight
|
||||||
|
: parseFloat(String(headerBackdropHeight)) || 0;
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const wrapClass = `drawer-wrap drawer-wrap--${effectivePlacement}`;
|
||||||
|
const rootClass = cx(
|
||||||
|
"drawer-root",
|
||||||
|
visible && "drawer-root--visible",
|
||||||
|
visible && "drawer-root--active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = (
|
||||||
|
<div className={rootClass} role="presentation" aria-hidden={!visible}>
|
||||||
|
<div
|
||||||
|
className="drawer-mask"
|
||||||
|
onClick={onMaskClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Close drawer"
|
||||||
|
/>
|
||||||
|
<div className={wrapClass}>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"drawer-panel",
|
||||||
|
isMobile && "drawer-panel--mobile",
|
||||||
|
footer != null && "drawer-panel--has-footer",
|
||||||
|
showHeaderBackdrop && "drawer-panel--has-header-backdrop",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={panelStyle}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title != null ? titleId : undefined}
|
||||||
|
>
|
||||||
|
{headerBackdrop != null && showHeaderBackdrop && (
|
||||||
|
<div
|
||||||
|
className="drawer-header-backdrop"
|
||||||
|
style={{
|
||||||
|
height: backdropDisplayH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drawer-header-backdrop-blur"
|
||||||
|
style={{
|
||||||
|
opacity: 1 - footerScrollOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{headerBackdrop}
|
||||||
|
</div>
|
||||||
|
<div className="drawer-header-backdrop-inner">
|
||||||
|
{headerBackdrop}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(title != null || closable) && (
|
||||||
|
<div ref={headerMeasureRef} className="drawer-header">
|
||||||
|
{title != null ? (
|
||||||
|
typeof title === "string" ? (
|
||||||
|
<h2 id={titleId} className="drawer-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
) : (
|
||||||
|
<div id={titleId} className="drawer-title">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="drawer-title" />
|
||||||
|
)}
|
||||||
|
{closable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close drawer"
|
||||||
|
>
|
||||||
|
<IoClose aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showHeaderBackdrop && (
|
||||||
|
<div
|
||||||
|
className={cx("drawer-scroll-backdrop-header-top-gradient")}
|
||||||
|
style={{
|
||||||
|
height: `${DRAWER_SCROLL_GRADIENT_HEIGHT * (showHeaderBackdrop ? 2.5 : 1) + 30}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"drawer-body",
|
||||||
|
title == null && !closable && "drawer-body--flush",
|
||||||
|
footer != null && "drawer-body--has-footer",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drawer-scroll-wrapper"
|
||||||
|
style={{
|
||||||
|
"--drawer-scroll-simplebar-track-margin-top": `${scrolledBackdropHeaderHeight + DRAWER_SCROLL_GRADIENT_HEIGHT}px`,
|
||||||
|
"--drawer-scroll-simplebar-track-margin-bottom": `${showHeaderBackdrop ? DRAWER_SCROLL_GRADIENT_HEIGHT : footerClientHeight}px`,
|
||||||
|
"--drawer-scroll-top-gradient-height": `${DRAWER_SCROLL_GRADIENT_HEIGHT}px`,
|
||||||
|
"--drawer-scroll-bottom-gradient-height": `calc(100% - ${DRAWER_SCROLL_GRADIENT_HEIGHT}px)`,
|
||||||
|
marginBottom: `${!showHeaderBackdrop ? Math.max(30, footerClientHeight) : 0}px`,
|
||||||
|
marginTop: showHeaderBackdrop
|
||||||
|
? `0px`
|
||||||
|
: `${headerClientHeight - 9}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"drawer-scroll-top-gradient",
|
||||||
|
showHeaderBackdrop &&
|
||||||
|
"drawer-scroll-top-gradient--has-header-backdrop",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${showHeaderBackdrop ? backdropLayoutPx - DRAWER_SCROLL_GRADIENT_HEIGHT * 3.5 + 30 : -24}px`,
|
||||||
|
height: `${DRAWER_SCROLL_GRADIENT_HEIGHT * (showHeaderBackdrop ? 3.5 : 1) + 30}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="drawer-scroll-bottom-gradient"
|
||||||
|
style={{
|
||||||
|
height: `${DRAWER_SCROLL_GRADIENT_HEIGHT + 30}px`,
|
||||||
|
bottom: `${showHeaderBackdrop ? 0 : -30}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SimpleBar className="drawer-scroll-vscroll">
|
||||||
|
<i className="drawer-scroll-start" aria-hidden />
|
||||||
|
<div
|
||||||
|
ref={scrollContentRef}
|
||||||
|
className="drawer-scroll-content"
|
||||||
|
style={{
|
||||||
|
marginTop: showHeaderBackdrop
|
||||||
|
? `${headerBackdropHeight}px`
|
||||||
|
: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{footer != null && (
|
||||||
|
<div
|
||||||
|
ref={footerMeasureRef}
|
||||||
|
className="drawer-footer"
|
||||||
|
style={{
|
||||||
|
top: showHeaderBackdrop
|
||||||
|
? `${backdropLayoutPx - footerClientHeight}px`
|
||||||
|
: "unset",
|
||||||
|
opacity: showHeaderBackdrop ? footerScrollOpacity : 1,
|
||||||
|
pointerEvents:
|
||||||
|
showHeaderBackdrop && footerScrollOpacity < 0.001
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(node, document.body);
|
||||||
|
}
|
||||||
6
packages/client/src/components/Flex.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/* Base shell; direction, alignment, and gap come from inline style (Ant Design–style API). */
|
||||||
|
.ta-flex {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
70
packages/client/src/components/Flex.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import "./Flex.css";
|
||||||
|
|
||||||
|
function mapJustifyAlign(value) {
|
||||||
|
if (value == null || value === "normal" || value === "stretch") return value;
|
||||||
|
if (value === "start") return "flex-start";
|
||||||
|
if (value === "end") return "flex-end";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gapToStyle(gap) {
|
||||||
|
if (gap == null) return undefined;
|
||||||
|
if (typeof gap === "number") return `${gap}px`;
|
||||||
|
if (Array.isArray(gap)) {
|
||||||
|
const [row, col] = gap;
|
||||||
|
const a = typeof row === "number" ? `${row}px` : row;
|
||||||
|
const b = col != null ? (typeof col === "number" ? `${col}px` : col) : a;
|
||||||
|
return `${a} ${b}`;
|
||||||
|
}
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flex container similar to Ant Design `Flex`: vertical, justify, align, gap, wrap.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} [props.vertical]
|
||||||
|
* @param {boolean | 'wrap' | 'nowrap' | 'wrap-reverse'} [props.wrap]
|
||||||
|
* @param {import('react').CSSProperties['justifyContent'] | 'start' | 'end'} [props.justify]
|
||||||
|
* @param {import('react').CSSProperties['alignItems'] | 'start' | 'end'} [props.align]
|
||||||
|
* @param {number | string | [number | string, number | string]} [props.gap]
|
||||||
|
* @param {import('react').CSSProperties['flex']} [props.flex]
|
||||||
|
* @param {keyof JSX.IntrinsicElements} [props.component]
|
||||||
|
*/
|
||||||
|
export function Flex({
|
||||||
|
vertical = false,
|
||||||
|
wrap = "nowrap",
|
||||||
|
justify = "normal",
|
||||||
|
align = "normal",
|
||||||
|
gap,
|
||||||
|
flex: flexProp,
|
||||||
|
component: Component = "div",
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const flexWrap =
|
||||||
|
wrap === true ? "wrap" : wrap === false ? "nowrap" : wrap;
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
flexDirection: vertical ? "column" : "row",
|
||||||
|
flexWrap,
|
||||||
|
justifyContent:
|
||||||
|
justify === "normal" ? undefined : mapJustifyAlign(justify),
|
||||||
|
alignItems: align === "normal" ? undefined : mapJustifyAlign(align),
|
||||||
|
gap: gapToStyle(gap),
|
||||||
|
flex: flexProp,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={["ta-flex", className].filter(Boolean).join(" ")}
|
||||||
|
style={merged}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
packages/client/src/components/HistoryLineChart.css
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
.history-line-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__period {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__plot {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
/* Let --history-chart-height control size (drawer charts use small heights). */
|
||||||
|
min-height: 240px;
|
||||||
|
height: var(--history-chart-height, 280px);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__plot .recharts-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__x-tick {
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__loading-overlay,
|
||||||
|
.history-line-chart__empty-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--history-chart-muted, rgba(0, 0, 0, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__loading-overlay {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--history-chart-surface, #fff) 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__empty-overlay {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--history-chart-surface, #fff) 55%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: history-line-chart-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__loading-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes history-line-chart-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match prior chart: slightly heavier grid lines */
|
||||||
|
.history-line-chart__plot .recharts-cartesian-grid line {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--history-chart-height, 280px);
|
||||||
|
color: var(--history-chart-muted, rgba(0, 0, 0, 0.45));
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-line-chart__error {
|
||||||
|
color: var(--history-chart-error, #cf1322);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recharts portals the tooltip to document body */
|
||||||
|
.recharts-tooltip-wrapper .history-line-chart__tooltip-panel {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
box-shadow: 0 10px 28px color-mix(in srgb, #000 45%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-wrapper,
|
||||||
|
*:focus-visible,
|
||||||
|
.recharts-wrapper,
|
||||||
|
*:focus-within {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
481
packages/client/src/components/HistoryLineChart.jsx
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useApi } from "../contexts/ApiContext.jsx";
|
||||||
|
import { Select } from "./Select.jsx";
|
||||||
|
import "./HistoryLineChart.css";
|
||||||
|
|
||||||
|
function parseHistoryPath(path) {
|
||||||
|
if (!path || typeof path !== "string") {
|
||||||
|
throw new Error("historyPath is required");
|
||||||
|
}
|
||||||
|
const parts = path.trim().split(".").filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error(`Invalid historyPath: ${path}`);
|
||||||
|
}
|
||||||
|
const root = parts[0].toLowerCase();
|
||||||
|
|
||||||
|
if (root === "patch") {
|
||||||
|
if (parts.length < 3) {
|
||||||
|
throw new Error("Expected patch.<patchId>.<field>");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "patch",
|
||||||
|
patchId: parts[1],
|
||||||
|
field: parts.slice(2).join("."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root === "power" || root === "powerstate") {
|
||||||
|
const fieldParts = parts
|
||||||
|
.slice(1)
|
||||||
|
.map((p) => (p.toLowerCase() === "batter" ? "battery" : p));
|
||||||
|
if (!fieldParts.length) {
|
||||||
|
throw new Error("Expected power.<nested.field>");
|
||||||
|
}
|
||||||
|
return { kind: "power", fieldParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root === "watertank") {
|
||||||
|
const fieldParts = parts.length > 1 ? parts.slice(1) : ["level"];
|
||||||
|
return { kind: "waterTank", fieldParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown history path root: ${parts[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series styling and tooltip unit by semantic property type (key = classifyHistoryPropertyType).
|
||||||
|
* Colors: temperature orange, humidity / level blue, default grey.
|
||||||
|
*/
|
||||||
|
const PROPERTY_TYPE_MAP = {
|
||||||
|
temperature: { color: "#ea580c", unit: "\u00B0C" },
|
||||||
|
percent: { color: "#3CD3FE", unit: "%" },
|
||||||
|
neutral: { color: "#9ca3af", unit: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyHistoryPropertyType(path) {
|
||||||
|
if (!path || typeof path !== "string") return "neutral";
|
||||||
|
const p = path.toLowerCase();
|
||||||
|
if (
|
||||||
|
p.includes("soiltemperature") ||
|
||||||
|
p.includes("temperature") ||
|
||||||
|
p.endsWith(".temp")
|
||||||
|
) {
|
||||||
|
return "temperature";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
p.includes("soilhumidity") ||
|
||||||
|
p.includes("humidity") ||
|
||||||
|
p.includes(".level") ||
|
||||||
|
p.endsWith("level")
|
||||||
|
) {
|
||||||
|
return "percent";
|
||||||
|
}
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistorySeriesMeta(path, lineColorOverride) {
|
||||||
|
const key = classifyHistoryPropertyType(path);
|
||||||
|
const entry = PROPERTY_TYPE_MAP[key] ?? PROPERTY_TYPE_MAP.neutral;
|
||||||
|
return {
|
||||||
|
propertyType: key,
|
||||||
|
color: lineColorOverride ?? entry.color,
|
||||||
|
unit: entry.unit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe display: formatted number + static unit suffix (no raw path / user strings). */
|
||||||
|
function formatHistoryTooltipValue(raw, unitSuffix) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) return "\u2014";
|
||||||
|
const formatted = new Intl.NumberFormat(undefined, {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(n);
|
||||||
|
return unitSuffix ? `${formatted}${unitSuffix}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNested(obj, pathParts) {
|
||||||
|
let x = obj;
|
||||||
|
for (const key of pathParts) {
|
||||||
|
x = x?.[key];
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsToChartPoints(rows, parsed) {
|
||||||
|
const out = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const t = new Date(row.sampledAt);
|
||||||
|
if (Number.isNaN(t.getTime())) continue;
|
||||||
|
|
||||||
|
let raw;
|
||||||
|
if (parsed.kind === "power") {
|
||||||
|
raw = getNested(row.power, parsed.fieldParts);
|
||||||
|
} else if (parsed.kind === "waterTank") {
|
||||||
|
raw = getNested(row.waterTank, parsed.fieldParts);
|
||||||
|
} else {
|
||||||
|
raw = row[parsed.field];
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = Number(raw);
|
||||||
|
if (!Number.isFinite(y)) continue;
|
||||||
|
out.push({ t, y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ value: "30m", label: "Last 30 minutes", ms: 30 * 60 * 1000 },
|
||||||
|
{ value: "1h", label: "Last hour", ms: 60 * 60 * 1000 },
|
||||||
|
{ value: "4h", label: "Last 4 hours", ms: 4 * 60 * 60 * 1000 },
|
||||||
|
{ value: "8h", label: "Last 8 hours", ms: 8 * 60 * 60 * 1000 },
|
||||||
|
{ value: "12h", label: "Last 12 hours", ms: 12 * 60 * 60 * 1000 },
|
||||||
|
{ value: "1d", label: "Last day", ms: 24 * 60 * 60 * 1000 },
|
||||||
|
{ value: "1w", label: "Last week", ms: 7 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ value: "1M", label: "Last month", ms: 30 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ value: "1y", label: "Last year", ms: 365 * 24 * 60 * 60 * 1000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function rangeForPeriod(periodValue) {
|
||||||
|
const p = PERIODS.find((x) => x.value === periodValue) ?? PERIODS[1];
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - p.ms);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom axis: time on first line, date on second (multi-line via tspans). */
|
||||||
|
function formatPrimaryAxisTickTwoLines(periodValue, date) {
|
||||||
|
const entry = PERIODS.find((x) => x.value === periodValue) ?? PERIODS[1];
|
||||||
|
const ms = entry.ms;
|
||||||
|
|
||||||
|
if (ms >= 365 * 24 * 60 * 60 * 1000) {
|
||||||
|
const top = new Intl.DateTimeFormat(undefined, { month: "short" }).format(
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
const bottom = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
return `${top}\n${bottom}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOpts =
|
||||||
|
ms <= 30 * 60 * 1000
|
||||||
|
? { hour: "numeric", minute: "2-digit", second: "2-digit" }
|
||||||
|
: { hour: "numeric", minute: "2-digit" };
|
||||||
|
|
||||||
|
const dateOpts =
|
||||||
|
ms <= 24 * 60 * 60 * 1000
|
||||||
|
? { weekday: "short", month: "short", day: "numeric" }
|
||||||
|
: { month: "short", day: "numeric", year: "numeric" };
|
||||||
|
|
||||||
|
const timeLine = new Intl.DateTimeFormat(undefined, timeOpts).format(date);
|
||||||
|
const dateLine = new Intl.DateTimeFormat(undefined, dateOpts).format(date);
|
||||||
|
return `${timeLine}\n${dateLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDate(value) {
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryTimeAxisTick({ x, y, payload, period }) {
|
||||||
|
const d = asDate(payload?.value);
|
||||||
|
if (!d) return null;
|
||||||
|
const lines = formatPrimaryAxisTickTwoLines(period, d)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x},${y + 10})`}>
|
||||||
|
<text
|
||||||
|
className="history-line-chart__x-tick"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--muted)"
|
||||||
|
fontSize={12}
|
||||||
|
dy={10}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<tspan key={i} x={0} dy={i === 0 ? 0 : 13}>
|
||||||
|
{line}
|
||||||
|
</tspan>
|
||||||
|
))}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryChartTooltip({ active, payload, unit }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const raw = payload[0]?.value;
|
||||||
|
return (
|
||||||
|
<div className="history-line-chart__tooltip-panel">
|
||||||
|
{formatHistoryTooltipValue(raw, unit)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartDataFromPoints(points) {
|
||||||
|
return points.map((p) => ({ t: p.t.getTime(), y: p.y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixel size of an element; updates on resize (flex/drawer/scroll layouts need this for Recharts). */
|
||||||
|
function useObservedSize(elementRef) {
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
const measure = () => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setSize({ width: r.width, height: r.height });
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === "undefined") return undefined;
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.historyPath — e.g. `patch.${patchId}.soilHumidity`, `power.battery.voltage`, `waterTank.level`
|
||||||
|
* @param {string} [props.lineColor] — overrides automatic orange (temperature) / blue (humidity) / grey default
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {React.CSSProperties} [props.style]
|
||||||
|
* @param {number|string} [props.chartHeight=280] — number is treated as px; string passes through (e.g. `15vh`)
|
||||||
|
*/
|
||||||
|
export function HistoryLineChart({
|
||||||
|
historyPath,
|
||||||
|
lineColor: lineColorProp,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
chartHeight = "280px",
|
||||||
|
}) {
|
||||||
|
const { getHistory, isConnected } = useApi();
|
||||||
|
const [period, setPeriod] = useState("1h");
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const parsed = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return parseHistoryPath(historyPath);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [historyPath]);
|
||||||
|
|
||||||
|
const parseError = useMemo(() => {
|
||||||
|
try {
|
||||||
|
parseHistoryPath(historyPath);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return e?.message || "Invalid history path";
|
||||||
|
}
|
||||||
|
}, [historyPath]);
|
||||||
|
|
||||||
|
const { color: lineColor, unit: valueUnit } = useMemo(
|
||||||
|
() => getHistorySeriesMeta(historyPath, lineColorProp),
|
||||||
|
[historyPath, lineColorProp],
|
||||||
|
);
|
||||||
|
const fillGradientId = `hlc-area-${useId().replace(/:/g, "")}`;
|
||||||
|
const plotRef = useRef(null);
|
||||||
|
const plotSize = useObservedSize(plotRef);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!parsed) return;
|
||||||
|
const { start, end } = rangeForPeriod(period);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
kind: parsed.kind,
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
};
|
||||||
|
if (parsed.kind === "patch") {
|
||||||
|
payload.patchId = parsed.patchId;
|
||||||
|
}
|
||||||
|
const data = await getHistory(payload);
|
||||||
|
setRows(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
setRows([]);
|
||||||
|
setError(e?.message || "Failed to load history");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [getHistory, parsed, period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!parsed || !isConnected) return;
|
||||||
|
load();
|
||||||
|
}, [parsed, period, isConnected, load]);
|
||||||
|
|
||||||
|
const points = useMemo(() => {
|
||||||
|
if (!parsed) return [];
|
||||||
|
return rowsToChartPoints(rows, parsed);
|
||||||
|
}, [rows, parsed]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => chartDataFromPoints(points), [points]);
|
||||||
|
|
||||||
|
const chartPixelW = Math.max(1, Math.floor(plotSize.width));
|
||||||
|
const chartPixelH = Math.max(1, Math.floor(plotSize.height));
|
||||||
|
const canRenderRecharts =
|
||||||
|
chartData.length > 0 && plotSize.width > 0 && plotSize.height > 0;
|
||||||
|
|
||||||
|
const timeTickRenderer = useMemo(
|
||||||
|
() => (props) => <HistoryTimeAxisTick {...props} period={period} />,
|
||||||
|
[period],
|
||||||
|
);
|
||||||
|
|
||||||
|
const heightCss =
|
||||||
|
typeof chartHeight === "number" ? `${chartHeight}px` : chartHeight;
|
||||||
|
|
||||||
|
const rootClass = ["history-line-chart", className].filter(Boolean).join(" ");
|
||||||
|
const plotStyle = {
|
||||||
|
...style,
|
||||||
|
"--history-chart-height": heightCss,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parseError) {
|
||||||
|
return (
|
||||||
|
<div className={rootClass} style={plotStyle}>
|
||||||
|
<div className="history-line-chart__state history-line-chart__error">
|
||||||
|
{parseError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClass} style={plotStyle}>
|
||||||
|
<div className="history-line-chart__toolbar">
|
||||||
|
<Select
|
||||||
|
className="history-line-chart__period"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
options={PERIODS.map((p) => ({ value: p.value, label: p.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="history-line-chart__plot" ref={plotRef}>
|
||||||
|
{error ? (
|
||||||
|
<div className="history-line-chart__state history-line-chart__error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : !isConnected ? (
|
||||||
|
<div className="history-line-chart__state">Connecting…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{canRenderRecharts ? (
|
||||||
|
<ResponsiveContainer width={chartPixelW} height={chartPixelH}>
|
||||||
|
<AreaChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ left: 0, right: 0, top: 1, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={fillGradientId}
|
||||||
|
x1="0"
|
||||||
|
x2="0"
|
||||||
|
y1="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={lineColor}
|
||||||
|
stopOpacity={0.25}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={lineColor}
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
vertical={false}
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="t"
|
||||||
|
type="number"
|
||||||
|
domain={["dataMin", "dataMax"]}
|
||||||
|
scale="time"
|
||||||
|
tick={timeTickRenderer}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
height={36}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
width={34}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "currentColor" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{
|
||||||
|
stroke: lineColor,
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: "4 4",
|
||||||
|
}}
|
||||||
|
content={<HistoryChartTooltip unit={valueUnit} />}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="y"
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#${fillGradientId})`}
|
||||||
|
fillOpacity={1}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, stroke: lineColor, fill: lineColor }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : null}
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
className="history-line-chart__loading-overlay"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="history-line-chart__spinner" aria-hidden />
|
||||||
|
<span className="history-line-chart__loading-label">
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!loading && points.length === 0 ? (
|
||||||
|
<div className="history-line-chart__empty-overlay">
|
||||||
|
No samples in this range
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
packages/client/src/components/IconButton.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/* Icon-only control: same motion/hover language as .btn, no border, compact */
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px;
|
||||||
|
min-height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
fill 0.3s ease,
|
||||||
|
stroke 0.3s ease,
|
||||||
|
transform 0.5s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0;
|
||||||
|
position: relative;
|
||||||
|
transform-origin: center center;
|
||||||
|
transform: scale(1);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-small {
|
||||||
|
padding: 0px;
|
||||||
|
min-height: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:not(:disabled):hover {
|
||||||
|
background-color: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:not(:disabled):active {
|
||||||
|
background-color: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
transform: scale(0.975);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn > svg {
|
||||||
|
fill: var(--text);
|
||||||
|
stroke: var(--text);
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
fill 0.3s ease,
|
||||||
|
stroke 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:not(:disabled):hover > svg,
|
||||||
|
.icon-btn:not(:disabled):active > svg {
|
||||||
|
fill: var(--bg);
|
||||||
|
stroke: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary:not(:disabled):hover {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary:not(:disabled):active {
|
||||||
|
filter: brightness(0.98);
|
||||||
|
transform: scale(0.975);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary > svg {
|
||||||
|
fill: var(--on-accent);
|
||||||
|
stroke: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary:not(:disabled):hover > svg,
|
||||||
|
.icon-btn--primary:not(:disabled):active > svg {
|
||||||
|
fill: var(--on-accent);
|
||||||
|
stroke: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--primary:disabled {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
44
packages/client/src/components/IconButton.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import "./IconButton.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {'default' | 'primary' | 'ghost'} [props.variant]
|
||||||
|
* @param {string} [props.href] — if set, renders `<a>`
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props['aria-label']] — recommended for accessibility (icon-only)
|
||||||
|
* @param {import('react').ReactNode} props.children — icon element(s)
|
||||||
|
*/
|
||||||
|
export function IconButton({
|
||||||
|
variant = "ghost",
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const classes = cx(
|
||||||
|
"icon-btn",
|
||||||
|
variant === "primary" && "icon-btn--primary",
|
||||||
|
className,
|
||||||
|
size === "small" && "icon-btn-small",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a href={href} className={classes} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type={type} className={classes} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
packages/client/src/components/Image.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/* Image — blurhash placeholder, loader, fade-in */
|
||||||
|
.tb-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-blurhash {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-error {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-error-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-error-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-loading-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-image-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
155
packages/client/src/components/Image.jsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { decode } from "blurhash";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { LoadingIcon } from "./LoadingIcon.jsx";
|
||||||
|
import "./Image.css";
|
||||||
|
|
||||||
|
const TRANSPARENT_PIXEL =
|
||||||
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||||
|
|
||||||
|
const BLURHASH_DECODE_W = 32;
|
||||||
|
const BLURHASH_DECODE_H = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image with optional blurhash placeholder, centered spinner while loading, and fade-in when ready.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.src
|
||||||
|
* @param {string} [props.blurHash]
|
||||||
|
* @param {string} [props.alt]
|
||||||
|
* @param {string} [props.className] — wrapper
|
||||||
|
* @param {string} [props.imgClassName] — inner img element
|
||||||
|
* @param {'lazy' | 'eager'} [props.loading]
|
||||||
|
* @param {import('react').CSSProperties} [props.style] — applied to the main img
|
||||||
|
*/
|
||||||
|
export function Image({
|
||||||
|
src,
|
||||||
|
blurHash,
|
||||||
|
alt = "",
|
||||||
|
className = "",
|
||||||
|
imgClassName = "",
|
||||||
|
loading = "lazy",
|
||||||
|
style,
|
||||||
|
...imgRest
|
||||||
|
}) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [blurhashDataUrl, setBlurhashDataUrl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoaded(false);
|
||||||
|
setError(false);
|
||||||
|
setAttempt(0);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = typeof blurHash === "string" ? blurHash.trim() : "";
|
||||||
|
if (!raw) {
|
||||||
|
setBlurhashDataUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pixels = decode(raw, BLURHASH_DECODE_W, BLURHASH_DECODE_H);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = BLURHASH_DECODE_W;
|
||||||
|
canvas.height = BLURHASH_DECODE_H;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
setBlurhashDataUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const imageData = ctx.createImageData(
|
||||||
|
BLURHASH_DECODE_W,
|
||||||
|
BLURHASH_DECODE_H,
|
||||||
|
);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
const url = canvas.toDataURL();
|
||||||
|
if (!cancelled) setBlurhashDataUrl(url);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setBlurhashDataUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [blurHash]);
|
||||||
|
|
||||||
|
const handleLoad = useCallback(() => {
|
||||||
|
setLoaded(true);
|
||||||
|
setError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleError = useCallback(() => {
|
||||||
|
setLoaded(false);
|
||||||
|
setError(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setError(false);
|
||||||
|
setLoaded(false);
|
||||||
|
setAttempt((a) => a + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLoader = !loaded && !error;
|
||||||
|
const showBlurLayer = Boolean(blurhashDataUrl) && !loaded && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={["tb-image-container", className].filter(Boolean).join(" ")}>
|
||||||
|
<img
|
||||||
|
src={blurhashDataUrl || TRANSPARENT_PIXEL}
|
||||||
|
alt=""
|
||||||
|
className="tb-image-blurhash"
|
||||||
|
aria-hidden
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
opacity: showBlurLayer ? 1 : 0,
|
||||||
|
transition: "opacity 0.25s ease-in-out",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showLoader ? (
|
||||||
|
<div className="tb-image-loading-container" aria-busy="true" aria-live="polite">
|
||||||
|
<div className="tb-image-loading">
|
||||||
|
<LoadingIcon aria-hidden color="white" size="22px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="tb-image-error">
|
||||||
|
<div className="tb-image-error-content">
|
||||||
|
<h3 className="tb-image-error-title">Failed to load image</h3>
|
||||||
|
<Button size="small" type="button" onClick={handleRetry}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
key={attempt}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
className={["tb-image-content", imgClassName].filter(Boolean).join(" ")}
|
||||||
|
style={{
|
||||||
|
opacity: loaded && !error ? 1 : 0,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
{...imgRest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Image;
|
||||||
106
packages/client/src/components/ImageList.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
.image-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper {
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper[draggable="true"] .image-list-item__thumb {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper[draggable="true"]:active .image-list-item__thumb {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper--dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper--dragging .image-list-item__remove-wrapper {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper--drag-over {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item-wrapper--drag-over::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
border: 2px dashed
|
||||||
|
color-mix(in srgb, var(--text, currentColor) 50%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item__thumb {
|
||||||
|
width: 112px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-elevated, rgba(0, 0, 0, 0.06));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item__photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item__remove-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list-item__remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
138
packages/client/src/components/ImageList.jsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Flex } from "./Flex.jsx";
|
||||||
|
import { UploadImage } from "./UploadImage.jsx";
|
||||||
|
import { ImageListItem } from "./ImageListItem.jsx";
|
||||||
|
import "./ImageList.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} entry
|
||||||
|
* @returns {{ id: string, blurHash: string } | null}
|
||||||
|
*/
|
||||||
|
function normalizeImageEntry(entry) {
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (typeof entry === "object" && entry !== null && entry.id != null) {
|
||||||
|
const id = String(entry.id);
|
||||||
|
if (!id || id === "undefined") return null;
|
||||||
|
const blurHash = typeof entry.blurHash === "string" ? entry.blurHash : "";
|
||||||
|
return { id, blurHash };
|
||||||
|
}
|
||||||
|
const id = String(entry);
|
||||||
|
return id && id !== "undefined" ? { id, blurHash: "" } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {(string | { id: string, blurHash?: string })[]} props.imageIds
|
||||||
|
* @param {(next: { id: string, blurHash: string }[]) => void} props.onChange
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
*/
|
||||||
|
export function ImageList({ imageIds, onChange, disabled }) {
|
||||||
|
const list = useMemo(() => {
|
||||||
|
const raw = Array.isArray(imageIds) ? imageIds : [];
|
||||||
|
return raw.map(normalizeImageEntry).filter(Boolean);
|
||||||
|
}, [imageIds]);
|
||||||
|
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||||
|
|
||||||
|
const addImage = useCallback(
|
||||||
|
(uploaded) => {
|
||||||
|
const n = normalizeImageEntry(uploaded);
|
||||||
|
if (!n || list.some((x) => x.id === n.id)) return;
|
||||||
|
onChange([...list, n]);
|
||||||
|
},
|
||||||
|
[list, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeAt = (index) => {
|
||||||
|
onChange(list.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(e, index) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setDraggedIndex(index);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/html", "");
|
||||||
|
},
|
||||||
|
[disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e, index) => {
|
||||||
|
if (disabled || draggedIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDragOverIndex(index);
|
||||||
|
},
|
||||||
|
[disabled, draggedIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e, dropIndex) => {
|
||||||
|
if (disabled || draggedIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (draggedIndex === dropIndex) {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...list];
|
||||||
|
const [removed] = next.splice(draggedIndex, 1);
|
||||||
|
next.splice(dropIndex, 0, removed);
|
||||||
|
onChange(next);
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
},
|
||||||
|
[disabled, draggedIndex, list, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={18}>
|
||||||
|
<div className="image-list">
|
||||||
|
{list.map((item, index) => {
|
||||||
|
const isDragging = draggedIndex === index;
|
||||||
|
const isDragOver = dragOverIndex === index && draggedIndex !== index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={[
|
||||||
|
"image-list-item-wrapper",
|
||||||
|
isDragging ? "image-list-item-wrapper--dragging" : "",
|
||||||
|
isDragOver ? "image-list-item-wrapper--drag-over" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
draggable={!disabled}
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<ImageListItem
|
||||||
|
imageId={item.id}
|
||||||
|
blurHash={item.blurHash || undefined}
|
||||||
|
onRemove={() => removeAt(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<UploadImage onUploaded={addImage} disabled={disabled} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
packages/client/src/components/ImageListItem.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { FaXmark } from "react-icons/fa6";
|
||||||
|
import { Image } from "./Image.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.imageId
|
||||||
|
* @param {string} [props.blurHash]
|
||||||
|
* @param {() => void} [props.onRemove]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
*/
|
||||||
|
export function ImageListItem({ imageId, blurHash, onRemove, disabled }) {
|
||||||
|
const src = `/api/images/${encodeURIComponent(imageId)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-list-item">
|
||||||
|
<div className="image-list-item__thumb">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
blurHash={blurHash || undefined}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="image-list-item__image"
|
||||||
|
imgClassName="image-list-item__photo"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="image-list-item__remove-wrapper">
|
||||||
|
<button
|
||||||
|
className="image-list-item__remove"
|
||||||
|
onClick={() => onRemove?.()}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<FaXmark aria-hidden size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
packages/client/src/components/ImageSlideshow.css
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
.image-slideshow {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__slides {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__slide--front {
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__slide--back {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow--crossfading .image-slideshow__slide--front {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow--crossfading .image-slideshow__slide--back {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__img-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__image-root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__photo {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-slideshow__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, var(--surface));
|
||||||
|
}
|
||||||
183
packages/client/src/components/ImageSlideshow.jsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Image } from "./Image.jsx";
|
||||||
|
import "./ImageSlideshow.css";
|
||||||
|
|
||||||
|
const SLIDE_INTERVAL_MS = 8000;
|
||||||
|
const FRONT_ENTRANCE_DELAY_MS = 10;
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {{ id: string, blurHash?: string }[] | string[]} props.images
|
||||||
|
* @param {string} [props.alt]
|
||||||
|
*/
|
||||||
|
export function ImageSlideshow({ images, alt = "" }) {
|
||||||
|
const list = useMemo(() => {
|
||||||
|
if (!Array.isArray(images)) return [];
|
||||||
|
return images
|
||||||
|
.map((entry) => {
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (typeof entry === "object" && entry.id != null) {
|
||||||
|
const blur = typeof entry.blurHash === "string" ? entry.blurHash : "";
|
||||||
|
return { id: String(entry.id), blurHash: blur };
|
||||||
|
}
|
||||||
|
const id = String(entry);
|
||||||
|
return id && id !== "undefined" ? { id, blurHash: "" } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
const listKey = useMemo(() => list.map((x) => x.id).join(","), [list]);
|
||||||
|
const n = list.length;
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
/** When true, DOM layer 0 is the visual front (higher opacity when idle). */
|
||||||
|
const [firstOnTop, setFirstOnTop] = useState(true);
|
||||||
|
const [crossfading, setCrossfading] = useState(false);
|
||||||
|
/** False briefly after mount / list change so the first front paint uses --back, then transitions to --front. */
|
||||||
|
const [initialFrontReady, setInitialFrontReady] = useState(false);
|
||||||
|
|
||||||
|
const slideTimerRef = useRef(null);
|
||||||
|
const fadeCommittedRef = useRef(false);
|
||||||
|
|
||||||
|
const clearSlideTimer = useCallback(() => {
|
||||||
|
if (slideTimerRef.current != null) {
|
||||||
|
clearTimeout(slideTimerRef.current);
|
||||||
|
slideTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleNextSlide = useCallback(() => {
|
||||||
|
clearSlideTimer();
|
||||||
|
if (n <= 1) return;
|
||||||
|
slideTimerRef.current = setTimeout(() => {
|
||||||
|
setCrossfading(true);
|
||||||
|
}, SLIDE_INTERVAL_MS);
|
||||||
|
}, [n, clearSlideTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIndex(0);
|
||||||
|
setFirstOnTop(true);
|
||||||
|
setCrossfading(false);
|
||||||
|
fadeCommittedRef.current = false;
|
||||||
|
clearSlideTimer();
|
||||||
|
setInitialFrontReady(false);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setInitialFrontReady(true);
|
||||||
|
}, FRONT_ENTRANCE_DELAY_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [listKey, clearSlideTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!n) return;
|
||||||
|
setIndex((i) => Math.min(i, n - 1));
|
||||||
|
}, [n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (n <= 1) return undefined;
|
||||||
|
scheduleNextSlide();
|
||||||
|
return clearSlideTimer;
|
||||||
|
}, [n, listKey, scheduleNextSlide, clearSlideTimer]);
|
||||||
|
|
||||||
|
const slideIndexForLayer = useCallback(
|
||||||
|
(layer /* 0 | 1 */) => {
|
||||||
|
if (n <= 1) return 0;
|
||||||
|
if (firstOnTop) {
|
||||||
|
return layer === 0 ? index : (index + 1) % n;
|
||||||
|
}
|
||||||
|
return layer === 0 ? (index + 1) % n : index;
|
||||||
|
},
|
||||||
|
[n, firstOnTop, index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCrossfadeEnd = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.propertyName !== "opacity" || !crossfading) return;
|
||||||
|
if (fadeCommittedRef.current) return;
|
||||||
|
fadeCommittedRef.current = true;
|
||||||
|
setCrossfading(false);
|
||||||
|
setIndex((prev) => (prev + 1) % n);
|
||||||
|
setFirstOnTop((f) => !f);
|
||||||
|
scheduleNextSlide();
|
||||||
|
},
|
||||||
|
[crossfading, n, scheduleNextSlide],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!crossfading) {
|
||||||
|
fadeCommittedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [crossfading]);
|
||||||
|
|
||||||
|
if (n === 0) {
|
||||||
|
return <div className="image-slideshow__empty" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slideTransitionStyle = {
|
||||||
|
transition: `opacity 0.45s ease-out, transform ${SLIDE_INTERVAL_MS}ms ease-in-out`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const layer0Front = firstOnTop && (crossfading || initialFrontReady);
|
||||||
|
const layer1Front = !firstOnTop && (crossfading || initialFrontReady);
|
||||||
|
|
||||||
|
if (n === 1) {
|
||||||
|
return (
|
||||||
|
<div className="image-slideshow">
|
||||||
|
<div className="image-slideshow__inner">
|
||||||
|
<div
|
||||||
|
className={`image-slideshow__slide${initialFrontReady ? " image-slideshow__slide--front" : " image-slideshow__slide--back"}`}
|
||||||
|
style={slideTransitionStyle}
|
||||||
|
>
|
||||||
|
<SlideContent slide={list[0]} alt={alt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`image-slideshow${crossfading ? " image-slideshow--crossfading" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="image-slideshow__inner">
|
||||||
|
<div className="image-slideshow__slides">
|
||||||
|
<div
|
||||||
|
className={`image-slideshow__slide${layer0Front ? " image-slideshow__slide--front" : " image-slideshow__slide--back"}`}
|
||||||
|
onTransitionEnd={layer0Front && firstOnTop ? handleCrossfadeEnd : undefined}
|
||||||
|
style={slideTransitionStyle}
|
||||||
|
>
|
||||||
|
<SlideContent slide={list[slideIndexForLayer(0)]} alt={alt} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`image-slideshow__slide${layer1Front ? " image-slideshow__slide--front" : " image-slideshow__slide--back"}`}
|
||||||
|
onTransitionEnd={layer1Front && !firstOnTop ? handleCrossfadeEnd : undefined}
|
||||||
|
style={slideTransitionStyle}
|
||||||
|
>
|
||||||
|
<SlideContent slide={list[slideIndexForLayer(1)]} alt={alt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {{ id: string, blurHash?: string }} props.slide
|
||||||
|
* @param {string} props.alt
|
||||||
|
*/
|
||||||
|
function SlideContent({ slide, alt }) {
|
||||||
|
const src = `/api/images/${encodeURIComponent(slide.id)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-slideshow__img-wrap">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
blurHash={slide.blurHash || undefined}
|
||||||
|
alt={alt}
|
||||||
|
className="image-slideshow__image-root"
|
||||||
|
imgClassName="image-slideshow__photo"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
packages/client/src/components/Input.css
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
/* Matches .btn padding, radius, and type scale (Button.css) */
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.input-affix-wrapper {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-height: 30px;
|
||||||
|
vertical-align: top;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:hover:not(:disabled):not(:read-only) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--borderless {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--borderless:hover:not(:disabled):not(:read-only) {
|
||||||
|
background-color: color-mix(in srgb, var(--text) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--borderless:focus-visible {
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes — small matches .btn--small; middle matches .btn; large steps up, same pill radius */
|
||||||
|
.input--sm,
|
||||||
|
.input-affix-wrapper--sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--sm {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper--sm {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--md,
|
||||||
|
.input-affix-wrapper--md {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--md {
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-height: 37px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper--md {
|
||||||
|
min-height: 37px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--lg,
|
||||||
|
.input-affix-wrapper--lg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--lg {
|
||||||
|
padding: 10px 16px;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper--lg {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.input--error,
|
||||||
|
.input-affix-wrapper--error {
|
||||||
|
border-color: color-mix(in srgb, #ff4d4f 70%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--error:focus-visible,
|
||||||
|
.input-affix-wrapper--error:focus-within {
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #ff4d4f 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--warning,
|
||||||
|
.input-affix-wrapper--warning {
|
||||||
|
border-color: color-mix(in srgb, #faad14 65%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--warning:focus-visible,
|
||||||
|
.input-affix-wrapper--warning:focus-within {
|
||||||
|
border-color: #faad14;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #faad14 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled,
|
||||||
|
.input-affix-wrapper--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 5%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper--disabled .input-affix-inner {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:read-only:not(:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Affix wrapper */
|
||||||
|
.input-affix-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper:hover:not(.input-affix-wrapper--disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper:focus-within:not(.input-affix-wrapper--disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-inner {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
height: 100%;
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-inner::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prefix,
|
||||||
|
.input-suffix {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background-color: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-clear:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-affix-wrapper--disabled .input-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Addon group (antd Input.Group style) */
|
||||||
|
.input-group {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > .input,
|
||||||
|
.input-group > .input-affix-wrapper {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .input,
|
||||||
|
.input-group .input-affix-wrapper {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > :first-child {
|
||||||
|
border-top-left-radius: 18px;
|
||||||
|
border-bottom-left-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > :last-child {
|
||||||
|
border-top-right-radius: 18px;
|
||||||
|
border-bottom-right-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > * + * {
|
||||||
|
margin-left: calc(var(--border-ui-width) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-addon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: color-mix(in srgb, var(--text) 6%, var(--bg));
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group--sm .input-addon {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group--lg .input-addon {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
252
packages/client/src/components/Input.jsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { IoCloseCircle } from "react-icons/io5";
|
||||||
|
import "./Input.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass = {
|
||||||
|
small: "sm",
|
||||||
|
middle: "md",
|
||||||
|
large: "lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–like controlled/uncontrolled text input.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} [props.value]
|
||||||
|
* @param {string} [props.defaultValue]
|
||||||
|
* @param {(e: import('react').ChangeEvent<HTMLInputElement>) => void} [props.onChange]
|
||||||
|
* @param {(e: import('react').KeyboardEvent<HTMLInputElement>) => void} [props.onPressEnter]
|
||||||
|
* @param {string} [props.placeholder]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} [props.readOnly]
|
||||||
|
* @param {boolean} [props.allowClear]
|
||||||
|
* @param {boolean} [props.bordered=true]
|
||||||
|
* @param {'large' | 'middle' | 'small'} [props.size='middle']
|
||||||
|
* @param {'' | 'error' | 'warning'} [props.status]
|
||||||
|
* @param {import('react').ReactNode} [props.prefix]
|
||||||
|
* @param {import('react').ReactNode} [props.suffix]
|
||||||
|
* @param {import('react').ReactNode} [props.addonBefore]
|
||||||
|
* @param {import('react').ReactNode} [props.addonAfter]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props.type='text']
|
||||||
|
*/
|
||||||
|
export const Input = forwardRef(function Input(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
onPressEnter,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
allowClear = false,
|
||||||
|
bordered = true,
|
||||||
|
size = "middle",
|
||||||
|
status,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
addonBefore,
|
||||||
|
addonAfter,
|
||||||
|
className,
|
||||||
|
type = "text",
|
||||||
|
maxLength,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
autoComplete,
|
||||||
|
style,
|
||||||
|
onKeyDown: onKeyDownProp,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const sz = sizeClass[size] ?? "md";
|
||||||
|
const innerRef = useRef(null);
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node) => {
|
||||||
|
innerRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [innerValue, setInnerValue] = useState(
|
||||||
|
() => defaultValue ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isControlled && defaultValue !== undefined) {
|
||||||
|
setInnerValue(defaultValue);
|
||||||
|
}
|
||||||
|
}, [defaultValue, isControlled]);
|
||||||
|
|
||||||
|
const displayValue = isControlled ? value ?? "" : innerValue;
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(next, event) => {
|
||||||
|
if (!isControlled) setInnerValue(next);
|
||||||
|
if (onChange && event) {
|
||||||
|
const target = Object.assign({}, event.target, { value: next });
|
||||||
|
const synthetic = Object.assign({}, event, {
|
||||||
|
target,
|
||||||
|
currentTarget: target,
|
||||||
|
});
|
||||||
|
onChange(synthetic);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isControlled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
emitChange(e.target.value, e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = innerRef.current;
|
||||||
|
if (el && !disabled && !readOnly) {
|
||||||
|
const synthetic = {
|
||||||
|
type: "change",
|
||||||
|
target: { value: "" },
|
||||||
|
currentTarget: { value: "" },
|
||||||
|
};
|
||||||
|
emitChange("", synthetic);
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
onKeyDownProp?.(e);
|
||||||
|
if (
|
||||||
|
onPressEnter &&
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.nativeEvent.isComposing &&
|
||||||
|
!e.shiftKey
|
||||||
|
) {
|
||||||
|
onPressEnter(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMod =
|
||||||
|
status === "error"
|
||||||
|
? "error"
|
||||||
|
: status === "warning"
|
||||||
|
? "warning"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showAffix = Boolean(prefix || suffix || allowClear);
|
||||||
|
const showClear =
|
||||||
|
allowClear &&
|
||||||
|
!disabled &&
|
||||||
|
!readOnly &&
|
||||||
|
String(displayValue).length > 0;
|
||||||
|
|
||||||
|
const sharedInputProps = {
|
||||||
|
ref: setRefs,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
placeholder,
|
||||||
|
maxLength,
|
||||||
|
autoComplete,
|
||||||
|
value: displayValue,
|
||||||
|
onChange: handleChange,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
"aria-invalid": status === "error" || undefined,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputEl = showAffix ? (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"input-affix-wrapper",
|
||||||
|
`input-affix-wrapper--${sz}`,
|
||||||
|
!bordered && "input--borderless",
|
||||||
|
disabled && "input-affix-wrapper--disabled",
|
||||||
|
statusMod && `input-affix-wrapper--${statusMod}`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{prefix ? <span className="input-prefix">{prefix}</span> : null}
|
||||||
|
<input
|
||||||
|
className={cx("input-affix-inner")}
|
||||||
|
{...sharedInputProps}
|
||||||
|
/>
|
||||||
|
{suffix || showClear ? (
|
||||||
|
<span className="input-suffix">
|
||||||
|
{suffix}
|
||||||
|
{showClear ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="input-clear"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Clear value"
|
||||||
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<IoCloseCircle size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
ref={setRefs}
|
||||||
|
className={cx(
|
||||||
|
"input",
|
||||||
|
`input--${sz}`,
|
||||||
|
!bordered && "input--borderless",
|
||||||
|
statusMod && `input--${statusMod}`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-invalid={status === "error" || undefined}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addonBefore != null || addonAfter != null) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx("input-group", `input-group--${sz}`)}
|
||||||
|
style={showAffix ? undefined : style}
|
||||||
|
>
|
||||||
|
{addonBefore != null ? (
|
||||||
|
<span className="input-addon">{addonBefore}</span>
|
||||||
|
) : null}
|
||||||
|
{inputEl}
|
||||||
|
{addonAfter != null ? (
|
||||||
|
<span className="input-addon">{addonAfter}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputEl;
|
||||||
|
});
|
||||||
3
packages/client/src/components/Label.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.label {
|
||||||
|
margin: 0 0 3px 0;
|
||||||
|
}
|
||||||
19
packages/client/src/components/Label.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import "./Label.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {import('react').ReactNode} props.label
|
||||||
|
* @param {'dt' | 'span' | 'label'} [props.as='dt']
|
||||||
|
* @param {string} [props.className]
|
||||||
|
*/
|
||||||
|
export function Label({ label, as: Component = "dt", className, ...rest }) {
|
||||||
|
return (
|
||||||
|
<Component className={cx("label", className)} {...rest}>
|
||||||
|
{label}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
packages/client/src/components/LifecyclePhase.css
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
.lifecycle-phases-edit__row {
|
||||||
|
padding: 5px 0 5px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__row-wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-left: 2px solid
|
||||||
|
color-mix(in srgb, var(--border-ui-color) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__phase-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Libre Baskerville", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__phase-timescale {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: "Libre Baskerville", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__row-wrapper:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__configs {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__field {
|
||||||
|
flex: 1 1 7rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__field--narrow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases--readonly .lifecycle-phases-edit__row-wrapper {
|
||||||
|
border-left: none;
|
||||||
|
flex-flow: row-reverse !important;
|
||||||
|
min-height: 91.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases--readonly .lifecycle-phases-edit__row {
|
||||||
|
padding: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases-edit__plant {
|
||||||
|
width: 130px;
|
||||||
|
margin-top: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases--readonly .lifecycle-phases-edit__plant {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
371
packages/client/src/components/LifecyclePhase.jsx
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import isoWeek from "dayjs/plugin/isoWeek";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { IoTrashOutline } from "react-icons/io5";
|
||||||
|
import { Flex } from "./Flex.jsx";
|
||||||
|
import { IconButton } from "./IconButton.jsx";
|
||||||
|
import { Input } from "./Input.jsx";
|
||||||
|
import { Label } from "./Label.jsx";
|
||||||
|
import { Slider } from "./Slider.jsx";
|
||||||
|
import { Plant } from "./allotment/Plant.jsx";
|
||||||
|
import "./LifecyclePhase.css";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(isoWeek);
|
||||||
|
|
||||||
|
const MAX_CAL_WEEK = 52;
|
||||||
|
const MAX_PHASE_LEAVES = 6;
|
||||||
|
const MAX_PHASE_FLOWERS = 3;
|
||||||
|
|
||||||
|
/** Jan 4 is always in ISO week 1; anchor a stable 0..52 grid for perennials. */
|
||||||
|
const PERENNIAL_REF_YEAR = 2024;
|
||||||
|
|
||||||
|
function perennialWeekGridStart() {
|
||||||
|
return dayjs.utc(`${PERENNIAL_REF_YEAR}-01-04`).startOf("isoWeek");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateAtPerennialWeekIndex(weekIndex) {
|
||||||
|
const w = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(MAX_CAL_WEEK, Math.floor(Number(weekIndex)) || 0),
|
||||||
|
);
|
||||||
|
return perennialWeekGridStart().add(w, "week");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlantedAtAnchor(raw) {
|
||||||
|
const s = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!s) {
|
||||||
|
return dayjs.utc(`${PERENNIAL_REF_YEAR}-01-01T12:00:00Z`).startOf("day");
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||||
|
return dayjs.utc(`${s}T12:00:00Z`).startOf("day");
|
||||||
|
}
|
||||||
|
const d = dayjs.utc(s);
|
||||||
|
return d.isValid()
|
||||||
|
? d
|
||||||
|
: dayjs.utc(`${PERENNIAL_REF_YEAR}-01-01T12:00:00Z`).startOf("day");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('dayjs').Dayjs} d
|
||||||
|
*/
|
||||||
|
function earlyLateMonthLabel(d) {
|
||||||
|
if (!d?.isValid()) return "";
|
||||||
|
const day = d.date();
|
||||||
|
const month = d.format("MMMM");
|
||||||
|
if (day <= 10) return `Early ${month}`;
|
||||||
|
if (day <= 20) return `Mid ${month}`;
|
||||||
|
return `Late ${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'biennial' | 'perennial'} variant
|
||||||
|
* @param {object} phase
|
||||||
|
* @param {number} weeks — span in weeks (same as the Weeks field)
|
||||||
|
* @param {string} [plantedAt]
|
||||||
|
*/
|
||||||
|
function phaseCalendarRangeLabel(variant, phase, weeks, plantedAt) {
|
||||||
|
const w = Math.max(0, Math.floor(Number(weeks)) || 0);
|
||||||
|
if (w <= 0) return null;
|
||||||
|
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
|
||||||
|
if (variant === "perennial") {
|
||||||
|
const startWeek = Math.floor(Number(phase?.startWeek) || 0);
|
||||||
|
start = dateAtPerennialWeekIndex(startWeek);
|
||||||
|
end = start.add(w, "week").subtract(1, "day");
|
||||||
|
} else {
|
||||||
|
const base = parsePlantedAtAnchor(plantedAt);
|
||||||
|
const startOff = Math.floor(Number(phase?.startOffsetWeeks) || 0);
|
||||||
|
const endOff = Math.floor(Number(phase?.endOffsetWeeks) || 0);
|
||||||
|
start = base.add(startOff, "week");
|
||||||
|
end = base.add(endOff, "week").subtract(1, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start.isValid() || !end.isValid() || end.isBefore(start)) return null;
|
||||||
|
|
||||||
|
const left = earlyLateMonthLabel(start);
|
||||||
|
const right = earlyLateMonthLabel(end);
|
||||||
|
if (left && right && left === right) return left;
|
||||||
|
if (left && right) return `${left} – ${right}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {number} props.phaseIndex
|
||||||
|
* @param {object} props.phase
|
||||||
|
* @param {'biennial' | 'perennial'} props.variant
|
||||||
|
* @param {number} props.weeks
|
||||||
|
* @param {string} props.nameFieldId
|
||||||
|
* @param {string} props.weeksFieldId
|
||||||
|
* @param {boolean} [props.editing=true]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} props.removeDisabled
|
||||||
|
* @param {number|null|undefined} [props.soilHumidity]
|
||||||
|
* @param {string} [props.plantedAt] — YYYY-MM-DD or ISO; biennial calendar labels only
|
||||||
|
* @param {() => void} props.onRemove
|
||||||
|
* @param {(name: string) => void} props.onNameChange
|
||||||
|
* @param {(raw: string) => void} props.onWeeksChange
|
||||||
|
* @param {(n: number) => void} props.onGrowingPositionChange
|
||||||
|
* @param {(n: number) => void} props.onWitheringChange
|
||||||
|
* @param {(n: number) => void} props.onLeavesChange
|
||||||
|
* @param {(n: number) => void} props.onFlowersChange
|
||||||
|
* @param {(n: number) => void} props.onFruitingChange
|
||||||
|
*/
|
||||||
|
export function LifecyclePhase({
|
||||||
|
phaseIndex,
|
||||||
|
phase,
|
||||||
|
variant,
|
||||||
|
weeks,
|
||||||
|
nameFieldId,
|
||||||
|
weeksFieldId,
|
||||||
|
editing = true,
|
||||||
|
disabled,
|
||||||
|
removeDisabled,
|
||||||
|
soilHumidity,
|
||||||
|
plantedAt,
|
||||||
|
produceIcon,
|
||||||
|
onRemove,
|
||||||
|
onNameChange,
|
||||||
|
onWeeksChange,
|
||||||
|
onGrowingPositionChange,
|
||||||
|
onWitheringChange,
|
||||||
|
onLeavesChange,
|
||||||
|
onFlowersChange,
|
||||||
|
onFruitingChange,
|
||||||
|
}) {
|
||||||
|
const sliderIds = useId();
|
||||||
|
const growthLabelId = `${sliderIds}-growth`;
|
||||||
|
const witheringLabelId = `${sliderIds}-withering`;
|
||||||
|
const leavesLabelId = `${sliderIds}-leaves`;
|
||||||
|
const flowersLabelId = `${sliderIds}-flowers`;
|
||||||
|
const fruitingLabelId = `${sliderIds}-fruiting`;
|
||||||
|
const flowerCount = phase.flowers ?? 0;
|
||||||
|
|
||||||
|
const timescaleLabel = phaseCalendarRangeLabel(
|
||||||
|
variant,
|
||||||
|
phase,
|
||||||
|
weeks,
|
||||||
|
plantedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex className="lifecycle-phases-edit__row-wrapper" align="center">
|
||||||
|
{editing ? (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="lifecycle-phases-edit__remove"
|
||||||
|
aria-label={`Remove phase ${phaseIndex + 1}`}
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disabled || removeDisabled}
|
||||||
|
>
|
||||||
|
<IoTrashOutline size={18} aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={13}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
className="lifecycle-phases-edit__row"
|
||||||
|
>
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
<h1 className="lifecycle-phases-edit__phase-title">{phase.name}</h1>
|
||||||
|
{timescaleLabel ? (
|
||||||
|
<p
|
||||||
|
className="lifecycle-phases-edit__phase-timescale muted"
|
||||||
|
aria-label="Approximate calendar range for this phase"
|
||||||
|
>
|
||||||
|
{timescaleLabel}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
gap={10}
|
||||||
|
align="flex-end"
|
||||||
|
className="lifecycle-phases-edit__configs"
|
||||||
|
>
|
||||||
|
<Flex vertical gap={4} style={{ width: "100%" }}>
|
||||||
|
<Label
|
||||||
|
label="Name"
|
||||||
|
as="label"
|
||||||
|
className="muted"
|
||||||
|
htmlFor={nameFieldId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={nameFieldId}
|
||||||
|
value={phase.name ?? ""}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="e.g. germination"
|
||||||
|
disabled={disabled}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={4}
|
||||||
|
className="lifecycle-phases-edit__field lifecycle-phases-edit__field--narrow"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label="Weeks"
|
||||||
|
as="label"
|
||||||
|
className="muted"
|
||||||
|
htmlFor={weeksFieldId}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id={weeksFieldId}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={variant === "perennial" ? MAX_CAL_WEEK : undefined}
|
||||||
|
value={weeks}
|
||||||
|
onChange={(e) => onWeeksChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
gap={10}
|
||||||
|
align="flex-end"
|
||||||
|
className="lifecycle-phases-edit__configs"
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={4}
|
||||||
|
className="lifecycle-phases-edit__field lifecycle-phases-edit__field--narrow"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label="Growth"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
id={growthLabelId}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={phase.growingPosition ?? 0}
|
||||||
|
onChange={onGrowingPositionChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={growthLabelId}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={4}
|
||||||
|
className="lifecycle-phases-edit__field lifecycle-phases-edit__field--narrow"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label="Withering"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
id={witheringLabelId}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={phase.withering ?? 0}
|
||||||
|
onChange={onWitheringChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={witheringLabelId}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
gap={10}
|
||||||
|
align="flex-end"
|
||||||
|
className="lifecycle-phases-edit__configs"
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={4}
|
||||||
|
className="lifecycle-phases-edit__field lifecycle-phases-edit__field--narrow"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label="Leaves"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
id={leavesLabelId}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={MAX_PHASE_LEAVES}
|
||||||
|
step={1}
|
||||||
|
value={phase.leaves ?? 0}
|
||||||
|
onChange={onLeavesChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={leavesLabelId}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
gap={4}
|
||||||
|
className="lifecycle-phases-edit__field lifecycle-phases-edit__field--narrow"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
label="Flowers"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
id={flowersLabelId}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={MAX_PHASE_FLOWERS}
|
||||||
|
step={1}
|
||||||
|
value={phase.flowers ?? 0}
|
||||||
|
onChange={onFlowersChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={flowersLabelId}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
{flowerCount > 0 ? (
|
||||||
|
<Flex
|
||||||
|
gap={10}
|
||||||
|
align="flex-end"
|
||||||
|
className="lifecycle-phases-edit__configs"
|
||||||
|
>
|
||||||
|
<Flex vertical gap={4} style={{ width: "100%" }}>
|
||||||
|
<Label
|
||||||
|
label="Fruiting"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
id={fruitingLabelId}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={phase.fruiting ?? 0}
|
||||||
|
onChange={onFruitingChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={fruitingLabelId}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
<div className="lifecycle-phases-edit__plant">
|
||||||
|
<Plant
|
||||||
|
position={phase.growingPosition}
|
||||||
|
withering={phase.withering ?? 0}
|
||||||
|
leaves={phase.leaves ?? 0}
|
||||||
|
flowers={phase.flowers ?? 0}
|
||||||
|
fruting={phase.fruiting ?? 0}
|
||||||
|
produceIcon={produceIcon}
|
||||||
|
soilHumidity={soilHumidity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
packages/client/src/components/LifecyclePhases.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.lifecycle-phases-edit {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-phases--readonly {
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: -22px;
|
||||||
|
}
|
||||||
206
packages/client/src/components/LifecyclePhases.jsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { useId } from "react";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { Flex } from "./Flex.jsx";
|
||||||
|
import { LifecyclePhase } from "./LifecyclePhase.jsx";
|
||||||
|
import "./LifecyclePhases.css";
|
||||||
|
|
||||||
|
const MAX_CAL_WEEK = 52;
|
||||||
|
|
||||||
|
function biennialWeeksFromPhase(p) {
|
||||||
|
const s = p.startOffsetWeeks ?? 0;
|
||||||
|
const e = p.endOffsetWeeks ?? 0;
|
||||||
|
return Math.max(0, e - s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Span for display; when start > end, phase crosses the year in 0..52 space. */
|
||||||
|
function perennialWeeksFromPhase(p) {
|
||||||
|
const s = p.startWeek ?? 0;
|
||||||
|
const e = p.endWeek ?? 0;
|
||||||
|
if (e >= s) return Math.max(0, e - s);
|
||||||
|
return Math.max(0, MAX_CAL_WEEK - s + e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildBiennialPhases(phases, weeksPerPhase) {
|
||||||
|
let cursor = 0;
|
||||||
|
return phases.map((p, i) => {
|
||||||
|
const w = Math.max(0, Math.floor(Number(weeksPerPhase[i]) || 0));
|
||||||
|
const startOffsetWeeks = cursor;
|
||||||
|
const endOffsetWeeks = startOffsetWeeks + w;
|
||||||
|
cursor = endOffsetWeeks;
|
||||||
|
return { ...p, startOffsetWeeks, endOffsetWeeks };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sequential segments from week 0; each phase is [start, end) clamped to 0..52. */
|
||||||
|
function rebuildPerennialPhases(phases, weeksPerPhase) {
|
||||||
|
let cursor = 0;
|
||||||
|
return phases.map((p, i) => {
|
||||||
|
const w = Math.max(0, Math.floor(Number(weeksPerPhase[i]) || 0));
|
||||||
|
const startWeek = Math.min(MAX_CAL_WEEK, cursor);
|
||||||
|
const endWeek = Math.min(MAX_CAL_WEEK, startWeek + w);
|
||||||
|
cursor = endWeek;
|
||||||
|
return { ...p, startWeek, endWeek };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} BiennialPhase
|
||||||
|
* @property {string} name
|
||||||
|
* @property {number} startOffsetWeeks
|
||||||
|
* @property {number} endOffsetWeeks
|
||||||
|
* @property {number} [growingPosition] — 0–100, Lottie growth stage
|
||||||
|
* @property {number} [withering] — 0–100, plant colour withering
|
||||||
|
* @property {number} [leaves] — 0–6, visible leaves on plant Lottie
|
||||||
|
* @property {number} [flowers] — 0–3, visible flowers on plant Lottie
|
||||||
|
* @property {number} [fruiting] — 0–100, fruit development when flowers > 0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} PerennialPhase
|
||||||
|
* @property {string} name
|
||||||
|
* @property {number} startWeek
|
||||||
|
* @property {number} endWeek
|
||||||
|
* @property {number} [growingPosition] — 0–100
|
||||||
|
* @property {number} [withering] — 0–100
|
||||||
|
* @property {number} [leaves] — 0–6
|
||||||
|
* @property {number} [flowers] — 0–3
|
||||||
|
* @property {number} [fruiting] — 0–100
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {'biennial' | 'perennial'} props.variant
|
||||||
|
* @param {(BiennialPhase[] | PerennialPhase[])} props.phases
|
||||||
|
* @param {(next: BiennialPhase[] | PerennialPhase[]) => void} props.onChange
|
||||||
|
* @param {boolean} [props.editing=true]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {string} [props.plantedAt] — YYYY-MM-DD or ISO; anchors biennial week offsets for calendar labels
|
||||||
|
*/
|
||||||
|
export function LifecyclePhases({
|
||||||
|
variant,
|
||||||
|
phases,
|
||||||
|
onChange,
|
||||||
|
editing = true,
|
||||||
|
disabled,
|
||||||
|
soilHumidity,
|
||||||
|
plantedAt,
|
||||||
|
produceIcon,
|
||||||
|
}) {
|
||||||
|
const idPrefix = useId();
|
||||||
|
const list = Array.isArray(phases) ? phases : [];
|
||||||
|
|
||||||
|
const weeksRowForPhase = (p) =>
|
||||||
|
variant === "biennial"
|
||||||
|
? biennialWeeksFromPhase(p)
|
||||||
|
: perennialWeeksFromPhase(p);
|
||||||
|
|
||||||
|
const addPhase = () => {
|
||||||
|
const weeks = list.map(weeksRowForPhase);
|
||||||
|
weeks.push(1);
|
||||||
|
const blank =
|
||||||
|
variant === "biennial"
|
||||||
|
? {
|
||||||
|
name: "",
|
||||||
|
startOffsetWeeks: 0,
|
||||||
|
endOffsetWeeks: 1,
|
||||||
|
growingPosition: 0,
|
||||||
|
withering: 0,
|
||||||
|
leaves: 0,
|
||||||
|
flowers: 0,
|
||||||
|
fruiting: 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: "",
|
||||||
|
startWeek: 0,
|
||||||
|
endWeek: 1,
|
||||||
|
growingPosition: 0,
|
||||||
|
withering: 0,
|
||||||
|
leaves: 0,
|
||||||
|
flowers: 0,
|
||||||
|
fruiting: 0,
|
||||||
|
};
|
||||||
|
const extended = [...list, blank];
|
||||||
|
onChange(
|
||||||
|
variant === "biennial"
|
||||||
|
? rebuildBiennialPhases(extended, weeks)
|
||||||
|
: rebuildPerennialPhases(extended, weeks),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (index) => {
|
||||||
|
const weeks = list.map(weeksRowForPhase);
|
||||||
|
weeks.splice(index, 1);
|
||||||
|
const rest = list.filter((_, i) => i !== index);
|
||||||
|
onChange(
|
||||||
|
variant === "biennial"
|
||||||
|
? rebuildBiennialPhases(rest, weeks)
|
||||||
|
: rebuildPerennialPhases(rest, weeks),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchAt = (index, patch) => {
|
||||||
|
onChange(list.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPhaseWeeks = (index, raw) => {
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
const w = Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||||
|
const weeks = list.map((p, i) => (i === index ? w : weeksRowForPhase(p)));
|
||||||
|
onChange(
|
||||||
|
variant === "biennial"
|
||||||
|
? rebuildBiennialPhases(list, weeks)
|
||||||
|
: rebuildPerennialPhases(list, weeks),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={13}>
|
||||||
|
<div
|
||||||
|
className={`lifecycle-phases-edit lifecycle-phases--${editing ? "editable" : "readonly"}`}
|
||||||
|
>
|
||||||
|
{list.map((phase, index) => (
|
||||||
|
<LifecyclePhase
|
||||||
|
key={index}
|
||||||
|
phaseIndex={index}
|
||||||
|
phase={phase}
|
||||||
|
variant={variant}
|
||||||
|
weeks={weeksRowForPhase(phase)}
|
||||||
|
nameFieldId={`${idPrefix}-name-${index}`}
|
||||||
|
weeksFieldId={`${idPrefix}-weeks-${index}`}
|
||||||
|
editing={editing}
|
||||||
|
disabled={disabled}
|
||||||
|
produceIcon={produceIcon}
|
||||||
|
removeDisabled={list.length <= 1}
|
||||||
|
soilHumidity={soilHumidity}
|
||||||
|
plantedAt={plantedAt}
|
||||||
|
onRemove={() => removeAt(index)}
|
||||||
|
onNameChange={(name) => patchAt(index, { name })}
|
||||||
|
onWeeksChange={(raw) => setPhaseWeeks(index, raw)}
|
||||||
|
onGrowingPositionChange={(n) =>
|
||||||
|
patchAt(index, { growingPosition: n })
|
||||||
|
}
|
||||||
|
onWitheringChange={(n) => patchAt(index, { withering: n })}
|
||||||
|
onLeavesChange={(n) => patchAt(index, { leaves: n })}
|
||||||
|
onFlowersChange={(n) =>
|
||||||
|
patchAt(
|
||||||
|
index,
|
||||||
|
n === 0 ? { flowers: n, fruiting: 0 } : { flowers: n },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onFruitingChange={(n) => patchAt(index, { fruiting: n })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={addPhase}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Add phase
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
packages/client/src/components/LoadingIcon.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.loading-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
animation: loading-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon path {
|
||||||
|
fill: var(--loading-icon-fill, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/client/src/components/LoadingIcon.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import LoadingSvg from "../../assets/svg/Loading.svg?react";
|
||||||
|
import "./LoadingIcon.css";
|
||||||
|
|
||||||
|
export function LoadingIcon({ className, size, color, style, ...props }) {
|
||||||
|
const mergedStyle = {
|
||||||
|
...(size ? { width: size, height: size } : {}),
|
||||||
|
...(color ? { "--loading-icon-fill": color } : {}),
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
function joinClassNames(...values) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingSvg
|
||||||
|
className={joinClassNames("loading-icon", className)}
|
||||||
|
style={Object.keys(mergedStyle).length ? mergedStyle : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/client/src/components/Metric.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function Metric({ title, children, className = "" }) {
|
||||||
|
return (
|
||||||
|
<div className={["metric", className].filter(Boolean).join(" ")}>
|
||||||
|
<span className="label">{title}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
packages/client/src/components/Metrics.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.metrics.metrics--vertical > * {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
36
packages/client/src/components/Metrics.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Flex } from "./Flex.jsx";
|
||||||
|
import "./Metrics.css";
|
||||||
|
|
||||||
|
/** Container for {@link Metric} children. `layout` controls row vs column flex direction. */
|
||||||
|
export function Metrics({
|
||||||
|
layout = "horizontal",
|
||||||
|
gap = "1rem",
|
||||||
|
wrap,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const vertical = layout === "vertical";
|
||||||
|
const mergedClass = [
|
||||||
|
"metrics",
|
||||||
|
vertical ? "metrics--vertical" : "metrics--horizontal",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical={vertical}
|
||||||
|
wrap={wrap ?? (vertical ? false : "wrap")}
|
||||||
|
gap={gap}
|
||||||
|
className={mergedClass}
|
||||||
|
style={style}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
packages/client/src/components/PageScroll.css
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
.page-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-sticky {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
z-index: 4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--app-main-padding-top) var(--app-main-padding-right) 0
|
||||||
|
calc(var(--sidebar-width) + var(--app-main-padding-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-sticky .page-title {
|
||||||
|
text-shadow: 0 0 20px var(--bg);
|
||||||
|
font-size: 38px;
|
||||||
|
font-family: "Libre Baskerville", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
top: var(--app-main-padding-top);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-top-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
top: -50px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 50%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 50%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-bottom-gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: unset;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 25%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 25%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
mask: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
black var(--page-scroll-top-gradient-height, 50px),
|
||||||
|
black var(--page-scroll-bottom-gradient-height, 50px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
black var(--page-scroll-top-gradient-height, 50px),
|
||||||
|
black var(--page-scroll-bottom-gradient-height, 50px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll .simplebar-vertical {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
width: var(--border-ui-width);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
margin-top: var(--simplebar-track-margin-top, 0);
|
||||||
|
margin-bottom: var(--app-main-padding-bottom);
|
||||||
|
margin-right: calc(var(--app-main-padding-right) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
width: var(--border-ui-width);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll.simplebar-scrolling::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll.simplebar-scrolling .simplebar-vertical {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--simplebar-track-margin-top, 50px) var(--app-main-padding-right)
|
||||||
|
2rem calc(var(--sidebar-width) + var(--app-main-padding-left));
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--app-main-padding-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-start {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: normal;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll .simplebar-scrollbar::before {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll-vscroll .simplebar-scrollbar.simplebar-visible,
|
||||||
|
.page-scroll-vscroll .simplebar-scrollbar.simplebar-visible::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
88
packages/client/src/components/PageScroll.jsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
import "./PageScroll.css";
|
||||||
|
import AnimatedText from "./AnimatedText.jsx";
|
||||||
|
|
||||||
|
const GRADIENT_HEIGHT = 80;
|
||||||
|
|
||||||
|
export function PageScroll({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
scrollable = true,
|
||||||
|
}) {
|
||||||
|
const simpleBarRef = useRef(null);
|
||||||
|
const [isScrollAtTop, setIsScrollAtTop] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const instance = simpleBarRef.current;
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollElement = instance.getScrollElement();
|
||||||
|
if (scrollElement) {
|
||||||
|
setIsScrollAtTop(scrollElement.scrollTop <= 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollElement = instance.getScrollElement();
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
scrollElement.addEventListener("scroll", handleScroll);
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
handleScroll();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(scrollElement);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener("scroll", handleScroll);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wrapperStyle = {
|
||||||
|
marginTop: `${30}px`,
|
||||||
|
"--simplebar-track-margin-top": `${GRADIENT_HEIGHT}px`,
|
||||||
|
"--page-scroll-top-gradient-height": `${GRADIENT_HEIGHT}px`,
|
||||||
|
"--page-scroll-bottom-gradient-height": `calc(100% - ${GRADIENT_HEIGHT}px)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`page-scroll ${className}`.trim()}>
|
||||||
|
<div
|
||||||
|
className={`page-scroll-sticky ${
|
||||||
|
isScrollAtTop ? "page-scroll-at-top" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="page-title">
|
||||||
|
<AnimatedText text={title} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page-scroll-wrapper" style={wrapperStyle}>
|
||||||
|
{scrollable && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="page-scroll-top-gradient"
|
||||||
|
style={{ height: `${GRADIENT_HEIGHT + 30}px` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="page-scroll-bottom-gradient"
|
||||||
|
style={{ height: `${GRADIENT_HEIGHT + 30}px` }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{scrollable ? (
|
||||||
|
<SimpleBar ref={simpleBarRef} className="page-scroll-vscroll">
|
||||||
|
<i className="page-scroll-start" aria-hidden />
|
||||||
|
<div className="page-scroll-content">{children}</div>
|
||||||
|
</SimpleBar>
|
||||||
|
) : (
|
||||||
|
<div className="page-scroll-content">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
packages/client/src/components/PlantGrid.css
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
.plant-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__col-controls,
|
||||||
|
.plant-grid__row-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__col-controls .icon-btn > svg,
|
||||||
|
.plant-grid__row-controls .icon-btn > svg {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__stepper-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap {
|
||||||
|
min-height: 68px;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 16px 10px 0 10px;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-row {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-column {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-host {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 40%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-row .plant-grid__matrix-host {
|
||||||
|
min-height: 0;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-column .plant-grid__matrix-host {
|
||||||
|
min-width: 0;
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
border-top: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-left: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-row .plant-grid__matrix {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__matrix-wrap.single-column .plant-grid__matrix {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__intersections {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__intersection-icon {
|
||||||
|
position: absolute;
|
||||||
|
color: var(--accent);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
margin-top: 2px;
|
||||||
|
/* horizontal center on intersection; bottom of icon sits on the intersection */
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-grid__cell {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
154
packages/client/src/components/PlantGrid.jsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { MdAdd, MdRemove } from "react-icons/md";
|
||||||
|
import { PiPlantFill } from "react-icons/pi";
|
||||||
|
import { Label } from "./Label.jsx";
|
||||||
|
import { IconButton } from "./IconButton.jsx";
|
||||||
|
import "./PlantGrid.css";
|
||||||
|
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual rows×columns grid with button-style lines, plant icons at line intersections, and steppers.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {number} props.rows
|
||||||
|
* @param {number} props.columns
|
||||||
|
* @param {(n: number) => void} props.onRowsChange
|
||||||
|
* @param {(n: number) => void} props.onColumnsChange
|
||||||
|
* @param {number} [props.min]
|
||||||
|
* @param {number} [props.max]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
*/
|
||||||
|
export function PlantGrid({
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
onRowsChange,
|
||||||
|
onColumnsChange,
|
||||||
|
min = 1,
|
||||||
|
max = 24,
|
||||||
|
disabled = false,
|
||||||
|
}) {
|
||||||
|
const r = clamp(Math.round(Number(rows)) || min, min, max);
|
||||||
|
const c = clamp(Math.round(Number(columns)) || min, min, max);
|
||||||
|
|
||||||
|
const singleRow = r === 1;
|
||||||
|
const singleColumn = c === 1;
|
||||||
|
|
||||||
|
const edge = "var(--border-ui-width) solid var(--border-ui-color)";
|
||||||
|
const cellRows = Math.max(0, r - 1);
|
||||||
|
const cellCols = Math.max(0, c - 1);
|
||||||
|
const cells = [];
|
||||||
|
for (let row = 0; row < cellRows; row++) {
|
||||||
|
for (let col = 0; col < cellCols; col++) {
|
||||||
|
cells.push(
|
||||||
|
<div
|
||||||
|
key={`${row}-${col}`}
|
||||||
|
className="plant-grid__cell"
|
||||||
|
style={{
|
||||||
|
borderRight: edge,
|
||||||
|
borderBottom: edge,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersectionIcons = [];
|
||||||
|
for (let j = 0; j <= r - 1; j++) {
|
||||||
|
for (let i = 0; i <= c - 1; i++) {
|
||||||
|
// Align to grid-line crossings: c columns ⇒ c lines at 0 … 100%, not c equal bands.
|
||||||
|
const leftPct = c <= 1 ? 50 : (i / (c - 1)) * 100;
|
||||||
|
const topPct = r <= 1 ? 50 : (j / (r - 1)) * 100;
|
||||||
|
intersectionIcons.push(
|
||||||
|
<PiPlantFill
|
||||||
|
key={`${j}-${i}`}
|
||||||
|
className="plant-grid__intersection-icon"
|
||||||
|
style={{
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
top: `${topPct}%`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plant-grid" role="group" aria-label="Patch plant grid size">
|
||||||
|
<div
|
||||||
|
className={`plant-grid__matrix-wrap ${singleRow ? "single-row" : ""} ${singleColumn ? "single-column" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="plant-grid__matrix-host">
|
||||||
|
<div className="plant-grid__intersections">{intersectionIcons}</div>
|
||||||
|
<div
|
||||||
|
className="plant-grid__matrix"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: `repeat(${cellRows}, 1fr)`,
|
||||||
|
gridTemplateColumns: `repeat(${cellCols}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="plant-grid__controls">
|
||||||
|
<div className="plant-grid__col-controls">
|
||||||
|
<Label
|
||||||
|
label="Col"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || c <= min}
|
||||||
|
onClick={() => onColumnsChange(clamp(c - 1, min, max))}
|
||||||
|
aria-label="Decrease columns"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MdRemove aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
<span className="plant-grid__stepper-value">{c}</span>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || c >= max}
|
||||||
|
onClick={() => onColumnsChange(clamp(c + 1, min, max))}
|
||||||
|
aria-label="Increase columns"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MdAdd aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="plant-grid__row-controls">
|
||||||
|
<Label
|
||||||
|
label="Row"
|
||||||
|
as="span"
|
||||||
|
className="muted"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || r <= min}
|
||||||
|
onClick={() => onRowsChange(clamp(r - 1, min, max))}
|
||||||
|
aria-label="Decrease rows"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MdRemove aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
<span className="plant-grid__stepper-value">{r}</span>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || r >= max}
|
||||||
|
onClick={() => onRowsChange(clamp(r + 1, min, max))}
|
||||||
|
aria-label="Increase rows"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MdAdd aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
packages/client/src/components/Popover.css
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
.popover-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-root {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1050;
|
||||||
|
border-radius: 25px;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 35px 0 rgba(0, 0, 0, 0.3),
|
||||||
|
0 3px 6px -4px rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
visibility 0.2s;
|
||||||
|
min-width: 177px;
|
||||||
|
max-width: min(360px, calc(100vw - 24px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-root::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border-radius: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-root .popover-root::before {
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-root.popover-open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-inner {
|
||||||
|
margin: 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-ui-color);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-inner-content {
|
||||||
|
color: color-mix(in srgb, var(--text) 85%, transparent);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-top {
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-top.popover-open {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-topLeft {
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-topLeft.popover-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-topRight {
|
||||||
|
bottom: 100%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-topRight.popover-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottom {
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-8px);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottom.popover-open {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottomLeft {
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottomLeft.popover-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottomRight {
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-bottomRight.popover-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-left {
|
||||||
|
right: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) translateX(8px);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-left.popover-open {
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-leftTop {
|
||||||
|
right: 100%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-leftTop.popover-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-leftBottom {
|
||||||
|
right: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(8px);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-leftBottom.popover-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-right {
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) translateX(-8px);
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-right.popover-open {
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-rightTop {
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-rightTop.popover-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-rightBottom {
|
||||||
|
left: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-rightBottom.popover-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
207
packages/client/src/components/Popover.jsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
cloneElement,
|
||||||
|
} from "react";
|
||||||
|
import "./Popover.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {import('react').ReactElement} props.children — single element; receives trigger handlers and ref
|
||||||
|
* @param {import('react').ReactNode} props.content
|
||||||
|
* @param {import('react').ReactNode} [props.title]
|
||||||
|
* @param {string | string[]} [props.trigger]
|
||||||
|
* @param {string} [props.placement]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} [props.open] — controlled (Ant-style); falls back to `visible`
|
||||||
|
* @param {boolean} [props.visible] — controlled (alias)
|
||||||
|
* @param {(open: boolean) => void} [props.onOpenChange]
|
||||||
|
* @param {(visible: boolean) => void} [props.onVisibleChange]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props.overlayClassName]
|
||||||
|
* @param {boolean} [props.defaultOpen]
|
||||||
|
*/
|
||||||
|
export function Popover({
|
||||||
|
children,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
trigger = ["hover"],
|
||||||
|
placement = "top",
|
||||||
|
disabled = false,
|
||||||
|
open: openProp,
|
||||||
|
visible: visibleProp,
|
||||||
|
onOpenChange,
|
||||||
|
onVisibleChange,
|
||||||
|
className = "",
|
||||||
|
overlayClassName = "",
|
||||||
|
defaultOpen = false,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const controlledValue =
|
||||||
|
openProp !== undefined ? openProp : visibleProp;
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
const [internalVisible, setInternalVisible] = useState(defaultOpen);
|
||||||
|
const visible = isControlled ? controlledValue : internalVisible;
|
||||||
|
|
||||||
|
const setVisible = useCallback(
|
||||||
|
(next) => {
|
||||||
|
if (!isControlled) setInternalVisible(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
onVisibleChange?.(next);
|
||||||
|
},
|
||||||
|
[isControlled, onOpenChange, onVisibleChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggers = Array.isArray(trigger) ? trigger : [trigger];
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (
|
||||||
|
wrapperRef.current &&
|
||||||
|
!wrapperRef.current.contains(event.target)
|
||||||
|
) {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visible && triggers.includes("click")) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [visible, triggers, setVisible]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (!disabled && triggers.includes("hover")) {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}, [disabled, triggers, setVisible]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (triggers.includes("hover")) {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}, [triggers, setVisible]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (triggers.includes("click")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setVisible(!visible);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, triggers, visible, setVisible],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
if (!disabled && triggers.includes("focus")) {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}, [disabled, triggers, setVisible]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
if (triggers.includes("focus")) {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
}, [triggers, setVisible]);
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (triggers.includes("contextMenu")) {
|
||||||
|
event.preventDefault();
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, triggers, setVisible],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, setVisible],
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerElement = cloneElement(children, {
|
||||||
|
ref: (node) => {
|
||||||
|
const r = children.ref;
|
||||||
|
if (typeof r === "function") r(node);
|
||||||
|
else if (r) r.current = node;
|
||||||
|
},
|
||||||
|
onClick: (e) => {
|
||||||
|
handleClick(e);
|
||||||
|
children.props?.onClick?.(e);
|
||||||
|
},
|
||||||
|
onMouseEnter: (e) => {
|
||||||
|
handleMouseEnter();
|
||||||
|
children.props?.onMouseEnter?.(e);
|
||||||
|
},
|
||||||
|
onMouseLeave: (e) => {
|
||||||
|
handleMouseLeave();
|
||||||
|
children.props?.onMouseLeave?.(e);
|
||||||
|
},
|
||||||
|
onFocus: (e) => {
|
||||||
|
handleFocus();
|
||||||
|
children.props?.onFocus?.(e);
|
||||||
|
},
|
||||||
|
onBlur: (e) => {
|
||||||
|
handleBlur();
|
||||||
|
children.props?.onBlur?.(e);
|
||||||
|
},
|
||||||
|
onContextMenu: (e) => {
|
||||||
|
handleContextMenu(e);
|
||||||
|
children.props?.onContextMenu?.(e);
|
||||||
|
},
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
handleKeyDown(e);
|
||||||
|
children.props?.onKeyDown?.(e);
|
||||||
|
},
|
||||||
|
"aria-haspopup": "true",
|
||||||
|
"aria-expanded": visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const placementClass = `popover-${placement}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={`popover-wrapper ${className}`.trim()}
|
||||||
|
onMouseEnter={
|
||||||
|
triggers.includes("hover") ? handleMouseEnter : undefined
|
||||||
|
}
|
||||||
|
onMouseLeave={
|
||||||
|
triggers.includes("hover") ? handleMouseLeave : undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{triggerElement}
|
||||||
|
<div
|
||||||
|
className={`popover-root ${placementClass} ${
|
||||||
|
visible ? "popover-open" : ""
|
||||||
|
} ${overlayClassName}`.trim()}
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div className="popover-inner">
|
||||||
|
{title ? <div className="popover-title">{title}</div> : null}
|
||||||
|
<div className="popover-inner-content">{content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Popover.displayName = "Popover";
|
||||||
66
packages/client/src/components/ProduceIcon.jsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import AppleSvg from "../../assets/svg/produce/apple.svg?react";
|
||||||
|
import BeetSvg from "../../assets/svg/produce/beet.svg?react";
|
||||||
|
import CabbageSvg from "../../assets/svg/produce/cabbage.svg?react";
|
||||||
|
import CarrotSvg from "../../assets/svg/produce/carrot.svg?react";
|
||||||
|
import CherrySvg from "../../assets/svg/produce/cherry.svg?react";
|
||||||
|
import CornSvg from "../../assets/svg/produce/corn.svg?react";
|
||||||
|
import CucumberSvg from "../../assets/svg/produce/cucumber.svg?react";
|
||||||
|
import KiwiSvg from "../../assets/svg/produce/kiwi.svg?react";
|
||||||
|
import LeekSvg from "../../assets/svg/produce/leek.svg?react";
|
||||||
|
import LettuceSvg from "../../assets/svg/produce/lettuce.svg?react";
|
||||||
|
import PeasSvg from "../../assets/svg/produce/peas.svg?react";
|
||||||
|
import PlumSvg from "../../assets/svg/produce/plum.svg?react";
|
||||||
|
import PotatoSvg from "../../assets/svg/produce/potato.svg?react";
|
||||||
|
import RadishSvg from "../../assets/svg/produce/radish.svg?react";
|
||||||
|
import RaspberrySvg from "../../assets/svg/produce/raspberry.svg?react";
|
||||||
|
import StrawberrySvg from "../../assets/svg/produce/strawberry.svg?react";
|
||||||
|
import TomatoSvg from "../../assets/svg/produce/tomato.svg?react";
|
||||||
|
import { DEFAULT_PRODUCE_ICON, PRODUCE_ICON_IDS } from "./produceIconIds.js";
|
||||||
|
|
||||||
|
const PRODUCE_SVGS = {
|
||||||
|
apple: AppleSvg,
|
||||||
|
beet: BeetSvg,
|
||||||
|
cabbage: CabbageSvg,
|
||||||
|
carrot: CarrotSvg,
|
||||||
|
cherry: CherrySvg,
|
||||||
|
corn: CornSvg,
|
||||||
|
cucumber: CucumberSvg,
|
||||||
|
kiwi: KiwiSvg,
|
||||||
|
leek: LeekSvg,
|
||||||
|
lettuce: LettuceSvg,
|
||||||
|
peas: PeasSvg,
|
||||||
|
plum: PlumSvg,
|
||||||
|
potato: PotatoSvg,
|
||||||
|
radish: RadishSvg,
|
||||||
|
raspberry: RaspberrySvg,
|
||||||
|
strawberry: StrawberrySvg,
|
||||||
|
tomato: TomatoSvg,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} [props.icon] — id matching `produceIconIds` (e.g. `tomato`)
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {import('react').CSSProperties} [props.style]
|
||||||
|
* @param {string} [props.title]
|
||||||
|
* @param {string} [props.role]
|
||||||
|
*/
|
||||||
|
export function ProduceIcon({ icon, className, style, title, role, ...rest }) {
|
||||||
|
const id =
|
||||||
|
typeof icon === "string" && PRODUCE_ICON_IDS.includes(icon.trim())
|
||||||
|
? icon.trim()
|
||||||
|
: DEFAULT_PRODUCE_ICON;
|
||||||
|
const Svg = PRODUCE_SVGS[id] ?? PRODUCE_SVGS[DEFAULT_PRODUCE_ICON];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
title={title}
|
||||||
|
role={role ?? (title ? "img" : undefined)}
|
||||||
|
aria-hidden={title ? undefined : true}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Svg className="produce-icon__svg" focusable="false" aria-hidden />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
packages/client/src/components/ProduceIconSelect.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
.produce-icon-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__anchor {
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 64×64 icon button: themed like .btn, same border-radius as .btn (18px) */
|
||||||
|
.produce-icon-select__trigger.btn {
|
||||||
|
width: 95px;
|
||||||
|
height: 95px;
|
||||||
|
min-height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__trigger.btn:hover {
|
||||||
|
background-color: var(--bg);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__trigger .produce-icon__svg {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 44px);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
border-color 0.3s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option .produce-icon__svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option:hover {
|
||||||
|
background-color: var(--border-ui-color);
|
||||||
|
color: var(--bg);
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option--selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.produce-icon-select__option--selected:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
80
packages/client/src/components/ProduceIconSelect.jsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { Popover } from "./Popover.jsx";
|
||||||
|
import { ProduceIcon } from "./ProduceIcon.jsx";
|
||||||
|
import { DEFAULT_PRODUCE_ICON, PRODUCE_ICON_IDS } from "./produceIconIds.js";
|
||||||
|
import "./ProduceIconSelect.css";
|
||||||
|
|
||||||
|
function optionLabel(id) {
|
||||||
|
return id.charAt(0).toUpperCase() + id.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.value
|
||||||
|
* @param {(next: string) => void} props.onChange
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {string} [props.id]
|
||||||
|
*/
|
||||||
|
export function ProduceIconSelect({ value, onChange, disabled, id }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const safe =
|
||||||
|
typeof value === "string" && PRODUCE_ICON_IDS.includes(value)
|
||||||
|
? value
|
||||||
|
: DEFAULT_PRODUCE_ICON;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className="produce-icon-select__grid"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Choose produce icon"
|
||||||
|
>
|
||||||
|
{PRODUCE_ICON_IDS.map((iconId) => (
|
||||||
|
<button
|
||||||
|
key={iconId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={iconId === safe}
|
||||||
|
className={
|
||||||
|
iconId === safe
|
||||||
|
? "produce-icon-select__option produce-icon-select__option--selected"
|
||||||
|
: "produce-icon-select__option"
|
||||||
|
}
|
||||||
|
title={optionLabel(iconId)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(iconId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProduceIcon icon={iconId} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="produce-icon-select">
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
placement="bottomLeft"
|
||||||
|
content={content}
|
||||||
|
>
|
||||||
|
<span className="produce-icon-select__anchor">
|
||||||
|
<Button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className="produce-icon-select__trigger"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`Produce icon: ${optionLabel(safe)}`}
|
||||||
|
>
|
||||||
|
<ProduceIcon icon={safe} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
packages/client/src/components/Select.css
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/* Custom select — behaviour from HomePanel Select; visuals aligned with Input / theme tokens */
|
||||||
|
|
||||||
|
.select-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px 8px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 30px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger--open {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-chevron {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger--open .select-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1050;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 8px 24px color-mix(in srgb, #000 22%, transparent);
|
||||||
|
max-height: 256px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
visibility 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel .select-dropdown {
|
||||||
|
z-index: 1500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown--open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options .simplebar-scrollbar::before {
|
||||||
|
background: color-mix(in srgb, var(--muted) 55%, var(--border-ui-color));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options .simplebar-scrollbar.simplebar-visible::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options:hover .simplebar-scrollbar::before {
|
||||||
|
background: color-mix(in srgb, var(--muted) 75%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options .simplebar-track.simplebar-vertical {
|
||||||
|
width: 8px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-options .simplebar-content-wrapper {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option:hover:not(.select-option--disabled) {
|
||||||
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option--selected {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option--disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option--disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option-label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-selected-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-empty {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
221
packages/client/src/components/Select.jsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { IoChevronDown } from "react-icons/io5";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
import "./Select.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom select with optional search. onChange receives a synthetic event
|
||||||
|
* `{ target: { value, name } }` like a native select element.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.value
|
||||||
|
* @param {(e: { target: { value: string, name?: string } }) => void} [props.onChange]
|
||||||
|
* @param {{ value: string, label: string, disabled?: boolean, search?: string }[]} [props.options]
|
||||||
|
* @param {string} [props.placeholder]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {string} [props.id]
|
||||||
|
* @param {boolean} [props.searchable]
|
||||||
|
* @param {(option: object) => import('react').ReactNode} [props.renderOption]
|
||||||
|
* @param {(option: object) => import('react').ReactNode} [props.renderSelected]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props.name]
|
||||||
|
*/
|
||||||
|
export function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options = [],
|
||||||
|
placeholder = "Select an option",
|
||||||
|
disabled = false,
|
||||||
|
id,
|
||||||
|
searchable = false,
|
||||||
|
renderOption,
|
||||||
|
renderSelected,
|
||||||
|
className = "",
|
||||||
|
name,
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
searchable && searchTerm
|
||||||
|
? options.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
opt?.search?.toLowerCase()?.includes(searchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
: options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchable && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen, searchable]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
if (!isOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (!isOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, isOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTriggerClick = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
if (isOpen) {
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionClick = (option) => {
|
||||||
|
if (option.disabled) return;
|
||||||
|
|
||||||
|
onChange?.({
|
||||||
|
target: {
|
||||||
|
value: option.value,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedContent = () => {
|
||||||
|
if (searchable && isOpen) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
className="select-search-input"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onFocus={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedOption) {
|
||||||
|
return <span className="select-placeholder">{placeholder}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderSelected) {
|
||||||
|
return renderSelected(selectedOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="select-selected-label">{selectedOption.label}</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOptionContent = (option) => {
|
||||||
|
if (renderOption) {
|
||||||
|
return renderOption(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="select-option-label">{option.label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={cx("select-wrap", className)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={id}
|
||||||
|
className={cx("select-trigger", isOpen && "select-trigger--open")}
|
||||||
|
onClick={handleTriggerClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<div className="select-value">{renderSelectedContent()}</div>
|
||||||
|
<span className="select-chevron" aria-hidden>
|
||||||
|
<IoChevronDown size={18} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx("select-dropdown", isOpen && "select-dropdown--open")}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
<SimpleBar className="select-options">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<div className="select-empty">No options found</div>
|
||||||
|
) : (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cx(
|
||||||
|
"select-option",
|
||||||
|
option.value === value && "select-option--selected",
|
||||||
|
option.disabled && "select-option--disabled",
|
||||||
|
)}
|
||||||
|
onClick={() => handleOptionClick(option)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
>
|
||||||
|
{renderOptionContent(option)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
packages/client/src/components/Sidebar.css
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 8vh;
|
||||||
|
top: 10vh;
|
||||||
|
bottom: 10vh;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px 25px 25px 25px;
|
||||||
|
height: 100%;
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 35px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-mobile-header {
|
||||||
|
right: 0;
|
||||||
|
margin-right: 10vw;
|
||||||
|
top: calc(10vh - 20px);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
scale 0.35s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-mobile-header--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0 -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-indicator {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
top 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sidebar-nav-indicator {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-icon-box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active .sidebar-link-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover:not(.sidebar-link--active) {
|
||||||
|
background: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link--active,
|
||||||
|
.sidebar-link--active:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
height: 0;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer .auth {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom {
|
||||||
|
left: calc(var(--app-main-padding-left) / 2);
|
||||||
|
right: calc(var(--app-main-padding-right) / 2);
|
||||||
|
|
||||||
|
margin-bottom: calc(var(--app-main-padding-right) / 2);
|
||||||
|
bottom: 0;
|
||||||
|
top: auto;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-brand {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-brand .sub {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-nav {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-nav .sidebar-link-icon-box {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-nav .sidebar-link-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-nav-indicator {
|
||||||
|
border-radius: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-link {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-radius: 25px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-link-text {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-footer {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
justify-self: end;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-footer .auth {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-footer .user {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom .sidebar-footer .btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--mobile-bottom.sidebar--drawer-open {
|
||||||
|
transform-origin: 50% 100%;
|
||||||
|
}
|
||||||
226
packages/client/src/components/Sidebar.jsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { matchPath, NavLink, useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
MdElectricBolt,
|
||||||
|
MdGridView,
|
||||||
|
MdWaterDrop,
|
||||||
|
MdPerson,
|
||||||
|
} from "react-icons/md";
|
||||||
|
import { HiClock } from "react-icons/hi2";
|
||||||
|
import { BiSolidBolt, BiWater } from "react-icons/bi";
|
||||||
|
import { FaHandHoldingWater } from "react-icons/fa";
|
||||||
|
import { PiPlantFill } from "react-icons/pi";
|
||||||
|
import { FaGlassWaterDroplet } from "react-icons/fa6";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { Flex } from "./Flex.jsx";
|
||||||
|
import { useAuth } from "../contexts/AuthContext.jsx";
|
||||||
|
import { useApi } from "../contexts/ApiContext.jsx";
|
||||||
|
import { useTheme } from "../contexts/ThemeContext.jsx";
|
||||||
|
import "./Sidebar.css";
|
||||||
|
|
||||||
|
// Each link: optional iconSize / mobileIconSize (px). Desktop: 18px-wide box, default 18×18 icon. Mobile bottom nav: 38px box, default 38×38. Smaller glyphs center in the box.
|
||||||
|
const MAIN_LINKS = [
|
||||||
|
{ path: "/patches", label: "Patches", Icon: PiPlantFill, mobileIconSize: 42 },
|
||||||
|
{
|
||||||
|
path: "/water-tank",
|
||||||
|
label: "Water tank",
|
||||||
|
Icon: FaGlassWaterDroplet,
|
||||||
|
iconSize: 17,
|
||||||
|
mobileIconSize: 34,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/power",
|
||||||
|
label: "Power",
|
||||||
|
Icon: BiSolidBolt,
|
||||||
|
iconSize: 22,
|
||||||
|
mobileIconSize: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/watering-sessions",
|
||||||
|
label: "Watering sessions",
|
||||||
|
Icon: MdWaterDrop,
|
||||||
|
mobileIconSize: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/water-schedules",
|
||||||
|
label: "Watering schedules",
|
||||||
|
Icon: HiClock,
|
||||||
|
mobileIconSize: 40,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSidebarLinkIconStyle(link, isMobile) {
|
||||||
|
const px = isMobile ? link.mobileIconSize : link.iconSize;
|
||||||
|
if (px == null) return undefined;
|
||||||
|
return { width: px, height: px };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { isConnected } = useApi();
|
||||||
|
const { isMobile } = useTheme();
|
||||||
|
const location = useLocation();
|
||||||
|
const pathname =
|
||||||
|
location.pathname.length > 1 && location.pathname.endsWith("/")
|
||||||
|
? location.pathname.slice(0, -1)
|
||||||
|
: location.pathname;
|
||||||
|
const isPatchesPage = pathname === "/patches";
|
||||||
|
|
||||||
|
const activeNavIndex = MAIN_LINKS.findIndex(
|
||||||
|
({ path }) => matchPath({ path, end: false }, pathname) != null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const navRef = useRef(null);
|
||||||
|
const linkRefs = useRef([]);
|
||||||
|
const [navIndicator, setNavIndicator] = useState({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNavIndicator = useCallback(() => {
|
||||||
|
const nav = navRef.current;
|
||||||
|
if (!nav) return;
|
||||||
|
if (activeNavIndex < 0) {
|
||||||
|
setNavIndicator((s) => ({ ...s, visible: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = linkRefs.current[activeNavIndex];
|
||||||
|
if (!link) return;
|
||||||
|
const nr = nav.getBoundingClientRect();
|
||||||
|
const lr = link.getBoundingClientRect();
|
||||||
|
setNavIndicator({
|
||||||
|
top: lr.top - nr.top + nav.scrollTop,
|
||||||
|
left: lr.left - nr.left + nav.scrollLeft,
|
||||||
|
width: lr.width,
|
||||||
|
height: lr.height,
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
}, [activeNavIndex]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateNavIndicator();
|
||||||
|
}, [updateNavIndicator, pathname, isMobile]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const nav = navRef.current;
|
||||||
|
if (!nav || typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(() => updateNavIndicator());
|
||||||
|
ro.observe(nav);
|
||||||
|
for (const el of linkRefs.current) {
|
||||||
|
if (el) ro.observe(el);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", updateNavIndicator);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", updateNavIndicator);
|
||||||
|
};
|
||||||
|
}, [updateNavIndicator]);
|
||||||
|
|
||||||
|
const footerContent = (
|
||||||
|
<Flex
|
||||||
|
className="auth"
|
||||||
|
align="center"
|
||||||
|
gap="10px"
|
||||||
|
component="div"
|
||||||
|
justify="space-between"
|
||||||
|
>
|
||||||
|
{user === undefined ? null : user ? (
|
||||||
|
<>
|
||||||
|
<Flex align="center" gap="10px">
|
||||||
|
<span className="sidebar-link-icon-box">
|
||||||
|
<MdPerson className="sidebar-link-icon" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{!isMobile && <span className="user">{user.name || user.sub}</span>}
|
||||||
|
</Flex>
|
||||||
|
<Button variant="ghost" href="/auth/logout">
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" href="/auth/login">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isPatchesPage
|
||||||
|
? "sidebar-mobile-header sidebar-mobile-header--visible"
|
||||||
|
: "sidebar-mobile-header"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{footerContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<aside
|
||||||
|
className={isMobile ? "sidebar sidebar--mobile-bottom" : "sidebar"}
|
||||||
|
>
|
||||||
|
<div className="sidebar-container">
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<h1 className="sidebar-title">Tom's Allotment</h1>
|
||||||
|
{!isConnected && (
|
||||||
|
<Flex className="sub" align="center" gap="0.5rem" component="p">
|
||||||
|
<span className="dot bad" />
|
||||||
|
<span>Reconnecting…</span>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav ref={navRef} className="sidebar-nav" aria-label="Main">
|
||||||
|
<div
|
||||||
|
className="sidebar-nav-indicator"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
top: navIndicator.top,
|
||||||
|
left: navIndicator.left,
|
||||||
|
width: navIndicator.width,
|
||||||
|
height: navIndicator.height,
|
||||||
|
opacity: navIndicator.visible ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{MAIN_LINKS.map((link, index) => {
|
||||||
|
const { path, label, Icon } = link;
|
||||||
|
const iconStyle = getSidebarLinkIconStyle(link, isMobile);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={path}
|
||||||
|
ref={(el) => {
|
||||||
|
linkRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
to={path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive
|
||||||
|
? "sidebar-link sidebar-link--active"
|
||||||
|
: "sidebar-link"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="sidebar-link-icon-box">
|
||||||
|
<Icon
|
||||||
|
className="sidebar-link-icon"
|
||||||
|
style={iconStyle}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{!isMobile && (
|
||||||
|
<span className="sidebar-link-text">{label}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
{!isMobile && <div className="sidebar-footer">{footerContent}</div>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
packages/client/src/components/Slider.css
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
.slider-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider--disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__rail {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__rail-hit {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 16px;
|
||||||
|
margin-top: -8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider--disabled .slider__rail-hit {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: -7px;
|
||||||
|
margin-left: -7px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg);
|
||||||
|
box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 1;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:not(.slider--disabled) .slider__handle:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 85%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__handle:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider__handle:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--bg),
|
||||||
|
0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:not(.slider--disabled) .slider__handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
230
packages/client/src/components/Slider.jsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import "./Slider.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {DOMRect} rect
|
||||||
|
* @param {number} min
|
||||||
|
* @param {number} max
|
||||||
|
* @param {number} step
|
||||||
|
*/
|
||||||
|
function valueFromPointer(clientX, rect, min, max, step) {
|
||||||
|
if (rect.width <= 0) return min;
|
||||||
|
const t = (clientX - rect.left) / rect.width;
|
||||||
|
const raw = min + t * (max - min);
|
||||||
|
if (step > 0) {
|
||||||
|
const steps = Math.round((raw - min) / step);
|
||||||
|
return clamp(min + steps * step, min, max);
|
||||||
|
}
|
||||||
|
return clamp(raw, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–like single-value horizontal slider (no antd/rc-slider).
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {number} [props.min=0]
|
||||||
|
* @param {number} [props.max=100]
|
||||||
|
* @param {number} props.value
|
||||||
|
* @param {(n: number) => void} props.onChange
|
||||||
|
* @param {number} [props.step=1]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props.id] — forwarded to the handle for label association
|
||||||
|
* @param {string} [props['aria-labelledby']]
|
||||||
|
* @param {string} [props['aria-label']]
|
||||||
|
*/
|
||||||
|
export function Slider({
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
"aria-labelledby": ariaLabelledBy,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}) {
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
const lo = Math.min(min, max);
|
||||||
|
const hi = Math.max(min, max);
|
||||||
|
const safeStep = step > 0 ? step : 1;
|
||||||
|
|
||||||
|
const clamped = useMemo(() => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return lo;
|
||||||
|
if (safeStep > 0) {
|
||||||
|
const steps = Math.round((n - lo) / safeStep);
|
||||||
|
return clamp(lo + steps * safeStep, lo, hi);
|
||||||
|
}
|
||||||
|
return clamp(n, lo, hi);
|
||||||
|
}, [value, lo, hi, safeStep]);
|
||||||
|
|
||||||
|
const percent = hi === lo ? 0 : ((clamped - lo) / (hi - lo)) * 100;
|
||||||
|
|
||||||
|
const emitFromClientX = useCallback(
|
||||||
|
(clientX) => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el || disabled) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const next = valueFromPointer(clientX, rect, lo, hi, safeStep);
|
||||||
|
if (next !== clamped) onChange(next);
|
||||||
|
},
|
||||||
|
[clamped, disabled, hi, lo, onChange, safeStep],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRailPointerDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (disabled || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
draggingRef.current = true;
|
||||||
|
emitFromClientX(e.clientX);
|
||||||
|
},
|
||||||
|
[disabled, emitFromClientX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRailPointerMove = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!draggingRef.current || disabled) return;
|
||||||
|
emitFromClientX(e.clientX);
|
||||||
|
},
|
||||||
|
[disabled, emitFromClientX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRailPointerUp = useCallback((e) => {
|
||||||
|
if (draggingRef.current) {
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draggingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onHandlePointerDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rail = wrapRef.current?.querySelector(".slider__rail-hit");
|
||||||
|
if (rail) {
|
||||||
|
rail.setPointerCapture(e.pointerId);
|
||||||
|
draggingRef.current = true;
|
||||||
|
}
|
||||||
|
emitFromClientX(e.clientX);
|
||||||
|
},
|
||||||
|
[disabled, emitFromClientX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
let delta = 0;
|
||||||
|
const page = hi - lo;
|
||||||
|
const big = Math.max(safeStep, page / 10);
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
case "ArrowUp":
|
||||||
|
delta = safeStep;
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "ArrowDown":
|
||||||
|
delta = -safeStep;
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(lo);
|
||||||
|
return;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(hi);
|
||||||
|
return;
|
||||||
|
case "PageUp":
|
||||||
|
delta = big;
|
||||||
|
break;
|
||||||
|
case "PageDown":
|
||||||
|
delta = -big;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const next = clamp(clamped + delta, lo, hi);
|
||||||
|
if (safeStep > 0) {
|
||||||
|
const steps = Math.round((next - lo) / safeStep);
|
||||||
|
onChange(clamp(lo + steps * safeStep, lo, hi));
|
||||||
|
} else {
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clamped, disabled, hi, lo, onChange, safeStep],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) return;
|
||||||
|
const onDocPointerUp = () => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerup", onDocPointerUp);
|
||||||
|
document.addEventListener("pointercancel", onDocPointerUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerup", onDocPointerUp);
|
||||||
|
document.removeEventListener("pointercancel", onDocPointerUp);
|
||||||
|
};
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="slider-wrap">
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className={cx("slider", disabled && "slider--disabled", className)}
|
||||||
|
>
|
||||||
|
<div className="slider__rail" aria-hidden />
|
||||||
|
<div
|
||||||
|
className="slider__track"
|
||||||
|
style={{ left: 0, width: `${percent}%` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="slider__rail-hit"
|
||||||
|
onPointerDown={onRailPointerDown}
|
||||||
|
onPointerMove={onRailPointerMove}
|
||||||
|
onPointerUp={onRailPointerUp}
|
||||||
|
onPointerCancel={onRailPointerUp}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className="slider__handle"
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
role="slider"
|
||||||
|
aria-valuemin={lo}
|
||||||
|
aria-valuemax={hi}
|
||||||
|
aria-valuenow={clamped}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
|
onPointerDown={onHandlePointerDown}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
packages/client/src/components/Switch.css
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
/* Ant Design–like switch; uses app :root tokens (App.css) */
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__track {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 44px;
|
||||||
|
width: 44px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: color-mix(in srgb, var(--text) 22%, transparent);
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
min-width 0.2s ease,
|
||||||
|
width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--checked .switch__track {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--with-inner .switch__track {
|
||||||
|
min-width: 52px;
|
||||||
|
width: auto;
|
||||||
|
padding-inline: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small.switch--with-inner .switch__track {
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--disabled .switch__track {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--loading {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small .switch__track {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 1px 3px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
transition: left 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small .switch__handle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--checked .switch__handle {
|
||||||
|
left: calc(100% - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small.switch--checked .switch__handle {
|
||||||
|
left: calc(100% - 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__loading-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--checked .switch__loading-icon path {
|
||||||
|
fill: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:not(.switch--checked) .switch__loading-icon path {
|
||||||
|
fill: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__inner-text {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small .switch__inner-text {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__inner-text--checked {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 22px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__inner-text--unchecked {
|
||||||
|
padding-left: 22px;
|
||||||
|
padding-right: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
color: color-mix(in srgb, var(--text) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch__inner-text--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small .switch__inner-text--checked {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch--small .switch__inner-text--unchecked {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
133
packages/client/src/components/Switch.jsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { LoadingIcon } from "./LoadingIcon";
|
||||||
|
import "./Switch.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–like toggle switch (no antd). Controlled or uncontrolled.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {boolean} [props.checked] — controlled
|
||||||
|
* @param {boolean} [props.defaultChecked=false] — uncontrolled initial
|
||||||
|
* @param {(checked: boolean, e: import('react').MouseEvent) => void} [props.onChange]
|
||||||
|
* @param {(e: import('react').MouseEvent) => void} [props.onClick]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} [props.loading]
|
||||||
|
* @param {'default' | 'small'} [props.size='default']
|
||||||
|
* @param {import('react').ReactNode} [props.checkedChildren]
|
||||||
|
* @param {import('react').ReactNode} [props.unCheckedChildren]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
* @param {string} [props.id]
|
||||||
|
*/
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
defaultChecked = false,
|
||||||
|
onChange,
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
size = "default",
|
||||||
|
checkedChildren,
|
||||||
|
unCheckedChildren,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const controlled = checked !== undefined;
|
||||||
|
const [innerChecked, setInnerChecked] = useState(defaultChecked);
|
||||||
|
const mergedChecked = controlled ? Boolean(checked) : innerChecked;
|
||||||
|
|
||||||
|
const mergedDisabled = disabled || loading;
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (mergedDisabled) return;
|
||||||
|
const next = !mergedChecked;
|
||||||
|
if (!controlled) setInnerChecked(next);
|
||||||
|
onChange?.(next, e);
|
||||||
|
},
|
||||||
|
[controlled, mergedChecked, mergedDisabled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
onClick?.(e);
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
toggle(e);
|
||||||
|
},
|
||||||
|
[onClick, toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasInner = checkedChildren != null || unCheckedChildren != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
id={id}
|
||||||
|
aria-checked={mergedChecked}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
disabled={mergedDisabled}
|
||||||
|
className={cx(
|
||||||
|
"switch",
|
||||||
|
size === "small" && "switch--small",
|
||||||
|
mergedChecked && "switch--checked",
|
||||||
|
mergedDisabled && "switch--disabled",
|
||||||
|
loading && "switch--loading",
|
||||||
|
hasInner && "switch--with-inner",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx("switch__track", !hasInner && "switch__track--fixed")}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{hasInner ? (
|
||||||
|
<span className="switch__ghost" aria-hidden>
|
||||||
|
<span className="switch__ghost-stack">
|
||||||
|
<span>{checkedChildren}</span>
|
||||||
|
<span>{unCheckedChildren}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="switch__face">
|
||||||
|
{hasInner ? (
|
||||||
|
<span className="switch__inner">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"switch__inner-text",
|
||||||
|
"switch__inner-text--checked",
|
||||||
|
mergedChecked && "switch__inner-text--visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checkedChildren}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"switch__inner-text",
|
||||||
|
"switch__inner-text--unchecked",
|
||||||
|
!mergedChecked && "switch__inner-text--visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{unCheckedChildren}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="switch__handle">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingIcon
|
||||||
|
className="switch__loading-icon"
|
||||||
|
size={size === "small" ? 10 : 12}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
packages/client/src/components/Table.css
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
.ta-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-table th,
|
||||||
|
.ta-table td {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-table tbody tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
21
packages/client/src/components/Table.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "./Table.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {import('react').ReactNode} [props.children]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
*/
|
||||||
|
export function Table({ className, children, ...rest }) {
|
||||||
|
return (
|
||||||
|
<div className="ta-table-wrap">
|
||||||
|
<table className={cx("ta-table", className)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
.temperature-humidity-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temperature-humidity-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MdThermostat, MdWaterDrop } from "react-icons/md";
|
||||||
|
import "./TemperatureHumidityDisplay.css";
|
||||||
|
|
||||||
|
export function TemperatureHumidityDisplay({
|
||||||
|
soilTemperature,
|
||||||
|
soilHumidity,
|
||||||
|
coloredIcons = true,
|
||||||
|
}) {
|
||||||
|
const thermostatStyle = coloredIcons
|
||||||
|
? { color: "#FF9230" }
|
||||||
|
: { color: "currentColor" };
|
||||||
|
const waterStyle = coloredIcons
|
||||||
|
? { color: "#3CD3FE", marginRight: "2px" }
|
||||||
|
: { color: "currentColor", marginRight: "2px" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="temperature-humidity-display">
|
||||||
|
<div className="temperature-humidity-stat">
|
||||||
|
<MdThermostat size={19} style={thermostatStyle} />
|
||||||
|
{soilTemperature != null
|
||||||
|
? `${soilTemperature.toFixed(1)}°C`
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<div className="temperature-humidity-stat">
|
||||||
|
<MdWaterDrop size={17} style={waterStyle} />
|
||||||
|
{soilHumidity != null ? `${Math.round(soilHumidity)}%` : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
packages/client/src/components/TextArea.css
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
@import "./Input.css";
|
||||||
|
|
||||||
|
/* Ant Design–style multiline field; shares Input border, radius, focus ring */
|
||||||
|
|
||||||
|
.textarea-affix-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
gap: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5715;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper:hover:not(.textarea-affix-wrapper--disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper:focus-within:not(.textarea-affix-wrapper--disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5715;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--sm .textarea-affix-inner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--md .textarea-affix-inner {
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--lg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--lg .textarea-affix-inner {
|
||||||
|
padding: 10px 16px;
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--borderless {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--borderless:hover:not(.textarea-affix-wrapper--disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--text) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--borderless:focus-within:not(.textarea-affix-wrapper--disabled) {
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--error {
|
||||||
|
border-color: color-mix(in srgb, #ff4d4f 70%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--error:focus-within {
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #ff4d4f 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--warning {
|
||||||
|
border-color: color-mix(in srgb, #faad14 65%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--warning:focus-within {
|
||||||
|
border-color: #faad14;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #faad14 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 5%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-inner {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-inner::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--disabled .textarea-affix-inner {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper .input-suffix {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--sm .input-suffix {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-affix-wrapper--lg .input-suffix {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standalone textarea (no affix) — same look as .input */
|
||||||
|
.textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5715;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--bg);
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:hover:not(:disabled):not(:read-only) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--borderless {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--borderless:hover:not(:disabled):not(:read-only) {
|
||||||
|
background-color: color-mix(in srgb, var(--text) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--borderless:focus-visible {
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--md {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--lg {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 10px 16px;
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--error {
|
||||||
|
border-color: color-mix(in srgb, #ff4d4f 70%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--error:focus-visible {
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #ff4d4f 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--warning {
|
||||||
|
border-color: color-mix(in srgb, #faad14 65%, var(--border-ui-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea--warning:focus-visible {
|
||||||
|
border-color: #faad14;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, #faad14 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: color-mix(in srgb, var(--text) 5%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:read-only:not(:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-count {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-count--error {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
269
packages/client/src/components/TextArea.jsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { IoCloseCircle } from "react-icons/io5";
|
||||||
|
import "./TextArea.css";
|
||||||
|
|
||||||
|
function cx(...parts) {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClass = {
|
||||||
|
small: "sm",
|
||||||
|
middle: "md",
|
||||||
|
large: "lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ant Design–like controlled/uncontrolled textarea (Input.TextArea style).
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} [props.value]
|
||||||
|
* @param {string} [props.defaultValue]
|
||||||
|
* @param {(e: import('react').ChangeEvent<HTMLTextAreaElement>) => void} [props.onChange]
|
||||||
|
* @param {(e: import('react').KeyboardEvent<HTMLTextAreaElement>) => void} [props.onPressEnter]
|
||||||
|
* @param {string} [props.placeholder]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {boolean} [props.readOnly]
|
||||||
|
* @param {boolean} [props.allowClear]
|
||||||
|
* @param {boolean} [props.bordered=true]
|
||||||
|
* @param {'large' | 'middle' | 'small'} [props.size='middle']
|
||||||
|
* @param {'' | 'error' | 'warning'} [props.status]
|
||||||
|
* @param {number} [props.rows=4]
|
||||||
|
* @param {boolean | { minRows?: number, maxRows?: number }} [props.autoSize]
|
||||||
|
* @param {boolean} [props.showCount]
|
||||||
|
* @param {number} [props.maxLength]
|
||||||
|
* @param {import('react').ReactNode} [props.suffix]
|
||||||
|
* @param {string} [props.className]
|
||||||
|
*/
|
||||||
|
export const TextArea = forwardRef(function TextArea(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
onPressEnter,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
allowClear = false,
|
||||||
|
bordered = true,
|
||||||
|
size = "middle",
|
||||||
|
status,
|
||||||
|
rows = 4,
|
||||||
|
autoSize = false,
|
||||||
|
showCount = false,
|
||||||
|
maxLength,
|
||||||
|
suffix,
|
||||||
|
className,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
onKeyDown: onKeyDownProp,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const sz = sizeClass[size] ?? "md";
|
||||||
|
const innerRef = useRef(null);
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node) => {
|
||||||
|
innerRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [innerValue, setInnerValue] = useState(() => defaultValue ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isControlled && defaultValue !== undefined) {
|
||||||
|
setInnerValue(defaultValue);
|
||||||
|
}
|
||||||
|
}, [defaultValue, isControlled]);
|
||||||
|
|
||||||
|
const displayValue = isControlled ? value ?? "" : innerValue;
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(next, event) => {
|
||||||
|
if (!isControlled) setInnerValue(next);
|
||||||
|
if (onChange && event) {
|
||||||
|
const target = Object.assign({}, event.target, { value: next });
|
||||||
|
const synthetic = Object.assign({}, event, {
|
||||||
|
target,
|
||||||
|
currentTarget: target,
|
||||||
|
});
|
||||||
|
onChange(synthetic);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isControlled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
emitChange(e.target.value, e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = innerRef.current;
|
||||||
|
if (el && !disabled && !readOnly) {
|
||||||
|
const synthetic = {
|
||||||
|
type: "change",
|
||||||
|
target: { value: "" },
|
||||||
|
currentTarget: { value: "" },
|
||||||
|
};
|
||||||
|
emitChange("", synthetic);
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
onKeyDownProp?.(e);
|
||||||
|
if (
|
||||||
|
onPressEnter &&
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.nativeEvent.isComposing &&
|
||||||
|
!e.shiftKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
onPressEnter(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const minRows =
|
||||||
|
autoSize && typeof autoSize === "object" ? autoSize.minRows : undefined;
|
||||||
|
const maxRows =
|
||||||
|
autoSize && typeof autoSize === "object" ? autoSize.maxRows : undefined;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!autoSize || !innerRef.current) return;
|
||||||
|
const el = innerRef.current;
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
const lineHeight = parseFloat(cs.lineHeight) || 22;
|
||||||
|
const padY =
|
||||||
|
parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) || 16;
|
||||||
|
const minH =
|
||||||
|
minRows != null
|
||||||
|
? minRows * lineHeight + padY
|
||||||
|
: parseFloat(cs.minHeight) || 64;
|
||||||
|
const maxH =
|
||||||
|
maxRows != null ? maxRows * lineHeight + padY : Number.POSITIVE_INFINITY;
|
||||||
|
el.style.height = "auto";
|
||||||
|
const next = Math.min(maxH, Math.max(minH, el.scrollHeight));
|
||||||
|
el.style.height = `${next}px`;
|
||||||
|
el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden";
|
||||||
|
}, [autoSize, displayValue, minRows, maxRows]);
|
||||||
|
|
||||||
|
const statusMod =
|
||||||
|
status === "error"
|
||||||
|
? "error"
|
||||||
|
: status === "warning"
|
||||||
|
? "warning"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const showAffix = Boolean(suffix || allowClear);
|
||||||
|
const showClear =
|
||||||
|
allowClear &&
|
||||||
|
!disabled &&
|
||||||
|
!readOnly &&
|
||||||
|
String(displayValue).length > 0;
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
ref: setRefs,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
placeholder,
|
||||||
|
maxLength: maxLength != null ? maxLength : undefined,
|
||||||
|
value: displayValue,
|
||||||
|
onChange: handleChange,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
rows: autoSize ? 1 : rows,
|
||||||
|
"aria-invalid": status === "error" || undefined,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const countStr =
|
||||||
|
maxLength != null
|
||||||
|
? `${String(displayValue).length} / ${maxLength}`
|
||||||
|
: `${String(displayValue).length}`;
|
||||||
|
|
||||||
|
const field = showAffix ? (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"textarea-affix-wrapper",
|
||||||
|
`textarea-affix-wrapper--${sz}`,
|
||||||
|
!bordered && "textarea-affix-wrapper--borderless",
|
||||||
|
disabled && "textarea-affix-wrapper--disabled",
|
||||||
|
statusMod && `textarea-affix-wrapper--${statusMod}`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="textarea-affix-inner"
|
||||||
|
style={autoSize ? { resize: "none", overflow: "hidden" } : undefined}
|
||||||
|
{...shared}
|
||||||
|
/>
|
||||||
|
{suffix || showClear ? (
|
||||||
|
<span className="input-suffix">
|
||||||
|
{suffix}
|
||||||
|
{showClear ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="input-clear"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Clear value"
|
||||||
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<IoCloseCircle size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
ref={setRefs}
|
||||||
|
className={cx(
|
||||||
|
"textarea",
|
||||||
|
`textarea--${sz}`,
|
||||||
|
!bordered && "textarea--borderless",
|
||||||
|
statusMod && `textarea--${statusMod}`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
autoSize
|
||||||
|
? { ...style, resize: "none", overflow: "hidden" }
|
||||||
|
: style
|
||||||
|
}
|
||||||
|
{...shared}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showCount) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="textarea-root" style={{ display: "block", width: "100%" }}>
|
||||||
|
{field}
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"textarea-count",
|
||||||
|
status === "error" && "textarea-count--error",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{countStr}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
packages/client/src/components/Untitled
Normal file
@ -0,0 +1 @@
|
|||||||
|
drawer-scroll-top-gradient--has-header-backdrop
|
||||||
58
packages/client/src/components/UploadImage.jsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button } from "./Button.jsx";
|
||||||
|
import { useApi } from "../contexts/ApiContext.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {(result: { id: string, blurHash?: string }) => void} [props.onUploaded]
|
||||||
|
* @param {boolean} [props.disabled]
|
||||||
|
* @param {string} [props.label]
|
||||||
|
*/
|
||||||
|
export function UploadImage({ onUploaded, disabled, label = "Add image" }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const { uploadImage } = useApi();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
setError("");
|
||||||
|
inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(file);
|
||||||
|
onUploaded?.(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
hidden
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={openPicker}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : label}
|
||||||
|
</Button>
|
||||||
|
{error ? <span className="hint">{error}</span> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
packages/client/src/components/WaterScheduler.css
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
.water-scheduler {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll-shell {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-right-gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
bottom: -30px;
|
||||||
|
right: -30px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: backdrop-filter 0.05s ease;
|
||||||
|
backdrop-filter: blur(0px);
|
||||||
|
-webkit-backdrop-filter: blur(0px);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 35%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
rgb(255 255 255) 0%,
|
||||||
|
rgb(255 255 255) 25%,
|
||||||
|
rgb(255 255 255 / 0%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll-shell--fade-right .water-scheduler-right-gradient {
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll-shell--fade-right .water-scheduler-hscroll {
|
||||||
|
mask: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black max(0%, calc(100% - var(--water-scheduler-h-fade-width, 88px))),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black max(0%, calc(100% - var(--water-scheduler-h-fade-width, 88px))),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll-shell:not(.water-scheduler-hscroll-shell--fade-right)
|
||||||
|
.water-scheduler-hscroll {
|
||||||
|
mask: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black max(0%, calc(100% - var(--water-scheduler-h-fade-width, 88px))),
|
||||||
|
black 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(
|
||||||
|
to right,
|
||||||
|
black 0,
|
||||||
|
black max(0%, calc(100% - var(--water-scheduler-h-fade-width, 88px))),
|
||||||
|
black 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll .simplebar-vertical {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll .simplebar-horizontal {
|
||||||
|
height: var(--border-ui-width);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
left: 0 !important;
|
||||||
|
right: var(--simplebar-track-margin-x, 88px);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: var(--simplebar-track-margin-x, 88px);
|
||||||
|
bottom: 0;
|
||||||
|
height: var(--border-ui-width);
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
border-radius: 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll.simplebar-scrolling::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll.simplebar-scrolling .simplebar-horizontal {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll .simplebar-scrollbar::before {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--border-ui-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hscroll .simplebar-scrollbar.simplebar-visible,
|
||||||
|
.water-scheduler-hscroll .simplebar-scrollbar.simplebar-visible::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-grid {
|
||||||
|
--water-scheduler-label: 148px;
|
||||||
|
--water-scheduler-track: min(2880px, max(1440px, 140vw));
|
||||||
|
min-width: calc(var(--water-scheduler-label) + var(--water-scheduler-track));
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 36px;
|
||||||
|
border-bottom: 2px solid var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-corner {
|
||||||
|
flex: 0 0 var(--water-scheduler-label);
|
||||||
|
min-width: var(--water-scheduler-label);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 2px 0 0 var(--border-ui-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hours {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 var(--water-scheduler-track);
|
||||||
|
min-width: var(--water-scheduler-track);
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hour {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
margin-left: -0.5px;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hour:before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: color-mix(in srgb, var(--border-ui-color) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-hour-text {
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 52px;
|
||||||
|
border-bottom: 2px solid
|
||||||
|
color-mix(in srgb, var(--border-ui-color) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item-label {
|
||||||
|
flex: 0 0 var(--water-scheduler-label);
|
||||||
|
min-width: var(--water-scheduler-label);
|
||||||
|
max-width: var(--water-scheduler-label);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 2px 0 0 var(--border-ui-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px 0 14px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item-label-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: color-mix(in srgb, var(--text) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-item-add:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 var(--water-scheduler-track);
|
||||||
|
min-width: var(--water-scheduler-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-track-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--border-ui-color) 50%, transparent) 0,
|
||||||
|
color-mix(in srgb, var(--border-ui-color) 50%, transparent) 2px,
|
||||||
|
transparent 2px,
|
||||||
|
transparent calc(100% / 24)
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
height: 28px;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: -14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||||
|
box-shadow: 0 1px 4px color-mix(in srgb, #000 35%, transparent);
|
||||||
|
cursor: default;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker--sunset {
|
||||||
|
background: color-mix(in srgb, var(--warn) 75%, var(--bg));
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 45%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker-edit {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
color: color-mix(in srgb, var(--text) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker-edit:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker--sunset .water-scheduler-marker-edit {
|
||||||
|
color: color-mix(in srgb, var(--text) 82%, var(--warn) 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-scheduler-marker-edit svg {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.water-scheduler-marker-edit:not(:disabled):hover {
|
||||||
|
transform: translateY(-50%) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.water-scheduler-marker-edit:not(:disabled):active {
|
||||||
|
transform: translateY(-50%) scale(0.975);
|
||||||
|
}
|
||||||
122
packages/client/src/components/WaterScheduler.jsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
import { WaterSchedulerRow } from "./WaterSchedulerRow.jsx";
|
||||||
|
import "./WaterScheduler.css";
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
const FADE_WIDTH = 88;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day timeline 00:00–23:59 in a horizontal scroll; one row per patch.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {{ id: string, name?: string }[]} props.patches
|
||||||
|
* @param {object[]} props.schedules
|
||||||
|
* @param {boolean} [props.canEdit]
|
||||||
|
* @param {(schedule: object) => void} [props.onEditSchedule]
|
||||||
|
* @param {(payload: object) => Promise<void>} [props.onAddWaterSchedule]
|
||||||
|
*/
|
||||||
|
export function WaterScheduler({
|
||||||
|
patches = [],
|
||||||
|
schedules = [],
|
||||||
|
canEdit = false,
|
||||||
|
onEditSchedule,
|
||||||
|
onAddWaterSchedule,
|
||||||
|
}) {
|
||||||
|
const simpleBarRef = useRef(null);
|
||||||
|
const [showRightFade, setShowRightFade] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const instance = simpleBarRef.current;
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = instance.getScrollElement();
|
||||||
|
if (!el) return;
|
||||||
|
const { scrollLeft, clientWidth, scrollWidth } = el;
|
||||||
|
const canScrollX = scrollWidth > clientWidth + 1;
|
||||||
|
const atEnd = scrollLeft + clientWidth >= scrollWidth - 2;
|
||||||
|
setShowRightFade(canScrollX && !atEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollElement = instance.getScrollElement();
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
scrollElement.addEventListener("scroll", handleScroll);
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
handleScroll();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(scrollElement);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener("scroll", handleScroll);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shellStyle = {
|
||||||
|
"--water-scheduler-h-fade-width": `${FADE_WIDTH}px`,
|
||||||
|
"--simplebar-track-margin-x": `${FADE_WIDTH}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const byPatch = new Map();
|
||||||
|
for (const s of schedules) {
|
||||||
|
const pid = s.patchId;
|
||||||
|
if (!pid) continue;
|
||||||
|
if (!byPatch.has(pid)) byPatch.set(pid, []);
|
||||||
|
byPatch.get(pid).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="water-scheduler">
|
||||||
|
<div
|
||||||
|
className={`water-scheduler-hscroll-shell ${
|
||||||
|
showRightFade ? "water-scheduler-hscroll-shell--fade-right" : ""
|
||||||
|
}`.trim()}
|
||||||
|
style={shellStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="water-scheduler-right-gradient"
|
||||||
|
style={{ width: `${FADE_WIDTH + 20}px` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<SimpleBar ref={simpleBarRef} className="water-scheduler-hscroll">
|
||||||
|
<div className="water-scheduler-grid">
|
||||||
|
<div className="water-scheduler-header-row">
|
||||||
|
<div className="water-scheduler-corner" aria-hidden />
|
||||||
|
<div className="water-scheduler-hours">
|
||||||
|
{HOURS.map((h) => (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
className="water-scheduler-hour"
|
||||||
|
style={{ left: `${(h / 24) * 100}%` }}
|
||||||
|
>
|
||||||
|
<span className="water-scheduler-hour-text">
|
||||||
|
{String(h).padStart(2, "0")}:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{patches.map((p) => (
|
||||||
|
<WaterSchedulerRow
|
||||||
|
key={p.id}
|
||||||
|
patchId={p.id}
|
||||||
|
patchLabel={p.name?.trim() ? p.name : p.id}
|
||||||
|
patches={patches}
|
||||||
|
schedules={byPatch.get(p.id) ?? []}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onEditSchedule={onEditSchedule}
|
||||||
|
onAddWaterSchedule={onAddWaterSchedule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
packages/client/src/components/WaterSchedulerItem.jsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { MdEdit } from "react-icons/md";
|
||||||
|
import {
|
||||||
|
formatMinutesAsClock,
|
||||||
|
timelineMinutesFromSchedule,
|
||||||
|
} from "../utils/waterScheduleTimeline.js";
|
||||||
|
import { IconButton } from "./IconButton.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single schedule bar on the 24h track.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {object} props.schedule
|
||||||
|
* @param {boolean} [props.canEdit]
|
||||||
|
* @param {(schedule: object) => void} [props.onEditSchedule]
|
||||||
|
*/
|
||||||
|
export function WaterSchedulerItem({
|
||||||
|
schedule: s,
|
||||||
|
canEdit = false,
|
||||||
|
onEditSchedule,
|
||||||
|
}) {
|
||||||
|
const minutes = timelineMinutesFromSchedule({
|
||||||
|
startAtType: s.startAtType,
|
||||||
|
offset: s.offset,
|
||||||
|
});
|
||||||
|
const leftPct = (minutes / 1440) * 100;
|
||||||
|
const dur = Math.max(
|
||||||
|
1,
|
||||||
|
Number.isFinite(Number(s.durationMinutes))
|
||||||
|
? Number(s.durationMinutes)
|
||||||
|
: 30,
|
||||||
|
);
|
||||||
|
/** Share of the 24h strip; capped so the bar stays within the track. */
|
||||||
|
const widthPct = Math.min(100 - leftPct, (dur / 1440) * 100);
|
||||||
|
const clock = formatMinutesAsClock(minutes);
|
||||||
|
const typeLabel =
|
||||||
|
s.startAtType === "sunset" ? "Sunset-based" : "Daylight-based";
|
||||||
|
const hum =
|
||||||
|
s.targetSoilHumidityEnabled && s.targetSoilHumidity != null
|
||||||
|
? ` · target ${s.targetSoilHumidity}%`
|
||||||
|
: "";
|
||||||
|
const wpRaw = Number(s.waterPower);
|
||||||
|
const wp = Number.isFinite(wpRaw)
|
||||||
|
? Math.min(100, Math.max(0, Math.round(wpRaw)))
|
||||||
|
: 100;
|
||||||
|
const title = `${clock} · ${typeLabel} · offset ${s.offset ?? 0} min · run ${dur} min · power ${wp}%${hum}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`water-scheduler-marker water-scheduler-marker--${s.startAtType}`}
|
||||||
|
style={{
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
minWidth: "6px",
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{canEdit && onEditSchedule ? (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
className="water-scheduler-marker-edit"
|
||||||
|
aria-label={`Edit watering schedule at ${clock}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEditSchedule(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdEdit aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
packages/client/src/components/WaterSchedulerRow.jsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { MdAdd } from "react-icons/md";
|
||||||
|
import { AddWaterScheduleDrawer } from "../drawers/AddWaterScheduleDrawer.jsx";
|
||||||
|
import { timelineMinutesFromSchedule } from "../utils/waterScheduleTimeline.js";
|
||||||
|
import { IconButton } from "./IconButton.jsx";
|
||||||
|
import { WaterSchedulerItem } from "./WaterSchedulerItem.jsx";
|
||||||
|
import "./WaterScheduler.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.patchId
|
||||||
|
* @param {string} props.patchLabel
|
||||||
|
* @param {{ id: string, name?: string }[]} props.patches
|
||||||
|
* @param {object[]} props.schedules
|
||||||
|
* @param {boolean} [props.canEdit]
|
||||||
|
* @param {(schedule: object) => void} [props.onEditSchedule]
|
||||||
|
* @param {(payload: object) => Promise<void>} [props.onAddWaterSchedule]
|
||||||
|
*/
|
||||||
|
export function WaterSchedulerRow({
|
||||||
|
patchId,
|
||||||
|
patchLabel,
|
||||||
|
patches = [],
|
||||||
|
schedules = [],
|
||||||
|
canEdit = false,
|
||||||
|
onEditSchedule,
|
||||||
|
onAddWaterSchedule,
|
||||||
|
}) {
|
||||||
|
const [addDrawerOpen, setAddDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="water-scheduler-item">
|
||||||
|
<div className="water-scheduler-item-label" title={patchLabel}>
|
||||||
|
<span className="water-scheduler-item-label-text">{patchLabel}</span>
|
||||||
|
{canEdit && onAddWaterSchedule ? (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
className="water-scheduler-item-add"
|
||||||
|
aria-label={`Add watering schedule for ${patchLabel}`}
|
||||||
|
onClick={() => setAddDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
<MdAdd aria-hidden />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="water-scheduler-track">
|
||||||
|
<div className="water-scheduler-track-grid" aria-hidden />
|
||||||
|
{[...schedules]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
timelineMinutesFromSchedule({
|
||||||
|
startAtType: a.startAtType,
|
||||||
|
offset: a.offset,
|
||||||
|
}) -
|
||||||
|
timelineMinutesFromSchedule({
|
||||||
|
startAtType: b.startAtType,
|
||||||
|
offset: b.offset,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map((schedule) => (
|
||||||
|
<WaterSchedulerItem
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onEditSchedule={onEditSchedule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{onAddWaterSchedule ? (
|
||||||
|
<AddWaterScheduleDrawer
|
||||||
|
open={addDrawerOpen}
|
||||||
|
onClose={() => setAddDrawerOpen(false)}
|
||||||
|
patches={patches}
|
||||||
|
initialPatchId={patchId}
|
||||||
|
onSubmit={onAddWaterSchedule}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
packages/client/src/components/allotment/Battery.css
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
.battery {
|
||||||
|
--battery-fill: var(--accent);
|
||||||
|
--battery-fill-deep: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
--battery-frame: color-mix(in srgb, var(--surface) 70%, var(--border) 30%);
|
||||||
|
--battery-notch: color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
position: relative;
|
||||||
|
width: 110px;
|
||||||
|
max-width: 210px;
|
||||||
|
aspect-ratio: 7 / 11;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__shell {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: var(--border-ui-width) solid var(--border-ui-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__notches {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 15px;
|
||||||
|
width: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__notch {
|
||||||
|
width: 7px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--battery-notch);
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__fill-wrap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__fill {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--battery-fill) 0%,
|
||||||
|
var(--battery-fill-deep) 100%
|
||||||
|
);
|
||||||
|
transition: height 0.65s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__fill--empty {
|
||||||
|
min-height: 0;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery__shine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, #fff 14%, transparent) 42%,
|
||||||
|
transparent 58%
|
||||||
|
);
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
51
packages/client/src/components/allotment/Battery.jsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import "./Battery.css";
|
||||||
|
|
||||||
|
const NOTCH_COUNT = 10;
|
||||||
|
|
||||||
|
function clampLevel(n) {
|
||||||
|
const x = Number(n);
|
||||||
|
if (!Number.isFinite(x)) return 0;
|
||||||
|
return Math.min(100, Math.max(0, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Battery({ level, className }) {
|
||||||
|
const pct = clampLevel(level);
|
||||||
|
const notches = useMemo(
|
||||||
|
() => Array.from({ length: NOTCH_COUNT }, (_, i) => i),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootClass = ["battery", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={rootClass}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Battery ${Math.round(pct)} percent`}
|
||||||
|
>
|
||||||
|
<div className="battery__shell">
|
||||||
|
<div className="battery__fill-wrap">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"battery__fill",
|
||||||
|
pct <= 0 ? "battery__fill--empty" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
style={{ height: pct <= 0 ? 0 : `calc(${pct}% - 10px)` }}
|
||||||
|
>
|
||||||
|
{pct > 0 ? (
|
||||||
|
<div className="battery__shine" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="battery__notches" aria-hidden>
|
||||||
|
{notches.map((i) => (
|
||||||
|
<span key={i} className="battery__notch" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||