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.

This commit is contained in:
Tom Butcher 2026-09-25 23:41:39 +01:00
commit 6e33faa5fd
167 changed files with 44165 additions and 0 deletions

45
.env.example Normal file
View 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
View File

@ -0,0 +1,5 @@
node_modules
dist
.env
*.log
.DS_Store

14
package.json Normal file
View 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"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View 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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 110 KiB

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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>
);
}

View 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);
}
}

View 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;

View 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);
}

View 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;

View 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;
}

View 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>
);
}

View 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;
}

View 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";

View 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;
}

View 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);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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;

View 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;
}

View 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>
);
}

View 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>
);
}

View 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));
}

View 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>
);
}

View 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;
}

View 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;
});

View File

@ -0,0 +1,3 @@
.label {
margin: 0 0 3px 0;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}
}

View 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}
/>
);
}

View 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>
);
}

View File

@ -0,0 +1,5 @@
.metrics.metrics--vertical > * {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}

View 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";

View 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>
);
}

View 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);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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%;
}

View 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&apos;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>
</>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}

View 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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View 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;
}

View 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>
);
});

View File

@ -0,0 +1 @@
drawer-scroll-top-gradient--has-header-backdrop

View 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}
</>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

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