From 929ee6557a7517041a1992c3656763ba5bc4f9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Mon, 6 Apr 2026 09:24:22 +0200 Subject: [PATCH] Introduce initial version of the Lonc app with core features, styling, and configurations. - Add base app structure, including Bootstrap setup and Alpine.js integration. - Implement authentication flow with session handling. - Integrate stock management and label creation functionalities. - Include responsive styling and theme using CSS variables and custom components. - Add API clients for Tryton-based backend. - Set up kitchen and dashboard navigation workflows. - Configure service worker for PWA support. --- .gitignore | 3 + .idea/.gitignore | 10 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/lonc.iml | 10 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 172 +++ index.html | 18 + package-lock.json | 1162 +++++++++++++++++ package.json | 18 + public/icons/icon-mask.svg | 5 + public/icons/icon.svg | 11 + public/manifest.webmanifest | 23 + public/offline.html | 43 + public/service-worker.js | 55 + src/api/auth.js | 111 ++ src/api/client.js | 119 ++ src/api/kitchens.js | 12 + src/api/labels.js | 55 + src/api/locations.js | 37 + src/api/stock.js | 74 ++ src/app/bootstrap.js | 91 ++ src/app/config.js | 39 + src/app/router.js | 95 ++ src/app/store.js | 104 ++ src/components/app-shell.js | 28 + src/components/confirm-dialog.js | 19 + src/components/empty-state.js | 10 + src/components/error-state.js | 7 + src/components/loading-state.js | 8 + src/components/nav-bar.js | 34 + src/features/auth/login-page.js | 225 ++++ src/features/auth/settings-page.js | 65 + src/features/dashboard/dashboard-page.js | 96 ++ src/features/kitchens/kitchen-selector.js | 73 ++ src/features/labels/label-create-page.js | 727 +++++++++++ src/features/register.js | 19 + src/features/shared/alerts.js | 18 + src/features/shared/date-utils.js | 16 + src/features/shared/form-utils.js | 20 + src/features/shared/storage.js | 24 + src/features/shared/ui-state.js | 20 + src/features/stock/stock-detail-page.js | 133 ++ src/features/stock/stock-list-page.js | 620 +++++++++ src/main.js | 11 + src/styles/app.css | 405 ++++++ vite.config.js | 7 + 48 files changed, 4879 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/lonc.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/icons/icon-mask.svg create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/offline.html create mode 100644 public/service-worker.js create mode 100644 src/api/auth.js create mode 100644 src/api/client.js create mode 100644 src/api/kitchens.js create mode 100644 src/api/labels.js create mode 100644 src/api/locations.js create mode 100644 src/api/stock.js create mode 100644 src/app/bootstrap.js create mode 100644 src/app/config.js create mode 100644 src/app/router.js create mode 100644 src/app/store.js create mode 100644 src/components/app-shell.js create mode 100644 src/components/confirm-dialog.js create mode 100644 src/components/empty-state.js create mode 100644 src/components/error-state.js create mode 100644 src/components/loading-state.js create mode 100644 src/components/nav-bar.js create mode 100644 src/features/auth/login-page.js create mode 100644 src/features/auth/settings-page.js create mode 100644 src/features/dashboard/dashboard-page.js create mode 100644 src/features/kitchens/kitchen-selector.js create mode 100644 src/features/labels/label-create-page.js create mode 100644 src/features/register.js create mode 100644 src/features/shared/alerts.js create mode 100644 src/features/shared/date-utils.js create mode 100644 src/features/shared/form-utils.js create mode 100644 src/features/shared/storage.js create mode 100644 src/features/shared/ui-state.js create mode 100644 src/features/stock/stock-detail-page.js create mode 100644 src/features/stock/stock-list-page.js create mode 100644 src/main.js create mode 100644 src/styles/app.css create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/lonc.iml b/.idea/lonc.iml new file mode 100644 index 0000000..b25733b --- /dev/null +++ b/.idea/lonc.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e83dcfb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9ad296b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3adf2c8 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Lonc + +Lonc is a responsive PWA frontend for household kitchen stock and labeling workflows backed by Tryton APIs. + +## Stack + +- Vite for a lightweight client build +- Bootstrap 5 for layout and form styling +- Alpine.js for local component state and interaction +- Plain `fetch()` service modules for backend communication +- Static manifest and service worker for PWA installability + +## Scripts + +- `npm install` +- `npm run dev` +- `npm run build` +- `npm run preview` + +## Production installation + +### Requirements + +- Node.js 20 or newer +- npm 10 or newer +- A static web server for the generated `dist/` directory +- A Tryton backend that exposes the `kitchen` user application and kitchen endpoints + +### Build for production + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Create the production build: + + ```bash + npm run build + ``` + +3. Deploy the generated `dist/` directory to your web server. + +### Serve the built app + +Lonc is a static frontend. In production you do not run a Node.js application server for it. You build the app once and serve the files from `dist/` with a normal static web server such as: + +- Nginx +- Apache +- Caddy +- a CDN/static hosting platform + +The frontend uses hash-based routing, so no special SPA history fallback is required for route handling. + +### Example deployment flow + +```bash +npm install +npm run build +rsync -av dist/ /var/www/lonc/ +``` + +Then configure your web server to serve `/var/www/lonc` as a static site. + +### Runtime configuration + +The application does not require build-time environment variables for the Tryton connection. Users configure the following in the login screen: + +- Tryton server base URL +- database name +- user login + +Authentication is done with Tryton user application keys for the `kitchen` application, not with JSON-RPC session login. + +### Reverse proxy / browser requirements + +If the frontend and Tryton backend are served from different origins, the Tryton server must allow cross-origin requests from the frontend origin. + +At minimum, production should ensure: + +- `Authorization` headers are accepted for API requests +- CORS is configured for the frontend origin when origins differ +- HTTPS is enabled in production + +### PWA notes + +For installability and service worker support: + +- serve `manifest.webmanifest` with an appropriate web manifest content type +- make sure `service-worker.js` is reachable from the deployed site root +- avoid aggressive caching on `index.html` during upgrades so new builds are picked up reliably + +### Smoke test after deployment + +After deployment, verify that: + +1. the site loads from the production URL +2. login can create a Tryton user application key +3. kitchen selection loads successfully +4. stock review and label creation can reach the backend +5. the browser can install the app as a PWA + +## Project structure + +```text +public/ + icons/ + manifest.webmanifest + offline.html + service-worker.js +src/ + api/ + app/ + components/ + features/ + styles/ + main.js +index.html +package.json +``` + +## Current MVP features + +- Login/configuration screen for Tryton server URL and database +- Session restore and logout shell +- Active kitchen selection and switching +- Dashboard with quick actions +- Label creation flow with item lookup, location loading, preview, and stock entry creation +- Stock list with search and filters +- Stock detail page with stock adjustment workflow +- PWA manifest, icons, service worker, and offline fallback + +## Tryton integration assumptions + +The frontend is intentionally organized around adapter-style API modules so the exact backend contract can be finalized without rewriting screens. + +Default endpoint placeholders live in [`src/app/config.js`](/Users/blaz/PycharmProjects/lonc/src/app/config.js), and the canonical URL builder lives in [`src/api/client.js`](/Users/blaz/PycharmProjects/lonc/src/api/client.js). + +Expected shapes today: + +- Kitchen-scoped application resources use: + `/{database}/kitchen/{kitchen_id}/{resource}` +- User application key management uses: + `/{database}/user/application/` +- Non-kitchen-scoped authenticated resources currently assume: + `/{database}/{resource}` + +- `POST /{database}/user/application/` + Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string. +- `DELETE /{database}/user/application/` + Sends `{ user, key, application: "kitchen" }` and disconnects the client. +- `GET /{database}/kitchen/kitchens` + Requires `Authorization: Bearer ` and is used as the current lightweight verification call after key approval. + Returns `{ data: [...] }` or `{ kitchens: [...] }`. +- `GET /{database}/kitchen/items?search_name=...` + Returns item definitions for autocomplete. +- `POST /{database}/kitchen/items?label=1` + Creates a stock item plus label-related output on the backend side. +- `POST /{database}/kitchen/items?label=1&preview=1` + Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. +- `GET /{database}/kitchen/locations` + Returns a nested location tree. +- `GET /{database}/kitchen/{kitchen_id}/stock`, `GET /{database}/kitchen/{kitchen_id}/stock/:id`, `POST /{database}/kitchen/{kitchen_id}/stock/:id/adjust` + Back the stock overview, creation, and adjustment workflows. + +## Notes + +- Hash-based routing is used to keep static deployment simple. +- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state. +- Kitchen context now lives in the URL path instead of a custom header. +- `includeKitchen: false` in the API client only removes the kitchen path segment; it does not disable bearer authentication. diff --git a/index.html b/index.html new file mode 100644 index 0000000..1815746 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Lonc + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eb2a6b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1162 @@ +{ + "name": "lonc-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lonc-web", + "version": "0.1.0", + "dependencies": { + "alpinejs": "^3.14.9", + "bootstrap": "^5.3.3" + }, + "devDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.11", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz", + "integrity": "sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d50036 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "lonc-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "alpinejs": "^3.14.9", + "bootstrap": "^5.3.3" + }, + "devDependencies": { + "vite": "^7.0.0" + } +} diff --git a/public/icons/icon-mask.svg b/public/icons/icon-mask.svg new file mode 100644 index 0000000..99ffd79 --- /dev/null +++ b/public/icons/icon-mask.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..33611ed --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..47f8e82 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Lonc", + "short_name": "Lonc", + "description": "Kitchen stock labeling and stock management client for Tryton-backed household workflows.", + "start_url": "/#/", + "display": "standalone", + "background_color": "#f4f7fb", + "theme_color": "#1f4b99", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-mask.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..bbf12d4 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,43 @@ + + + + + + Lonc Offline + + + +
+

You are offline

+

+ Lonc can still open its cached shell, but Tryton-backed data and save actions need the network. +

+

Reconnect and reload when you are ready to continue.

+
+ + diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..8630cfe --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,55 @@ +const CACHE_NAME = 'lonc-shell-v1'; +const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/offline.html', '/icons/icon.svg', '/icons/icon-mask.svg']; + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + + const requestUrl = new URL(event.request.url); + + if (requestUrl.origin !== self.location.origin) { + return; + } + + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).catch(async () => { + const shell = await caches.match('/index.html'); + return shell || caches.match('/offline.html'); + }), + ); + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + return ( + response || + fetch(event.request).then((networkResponse) => { + const clone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + return networkResponse; + }) + ); + }), + ); +}); diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..8e8770d --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,111 @@ +import { CONNECTION_STATES, TRYTON_APPLICATION } from '../app/config.js'; +import { apiRequest, getPath } from './client.js'; +import { listKitchens } from './kitchens.js'; + +function extractKey(payload) { + if (typeof payload === 'string') { + return payload; + } + + return payload?.key || payload?.application_key || payload?.data?.key || null; +} + +function isAuthFailure(error) { + const status = error?.cause?.status || error?.status; + return status === 401 || status === 403; +} + +export async function login(store, credentials) { + const payload = await apiRequest(store, getPath('userApplication'), { + method: 'POST', + body: { + user: credentials.userLogin, + application: TRYTON_APPLICATION, + }, + includeKitchen: false, + }); + + const applicationKey = extractKey(payload); + if (!applicationKey) { + throw new Error('User application creation did not return a key.'); + } + + const session = { + userLogin: credentials.userLogin, + applicationKey, + state: CONNECTION_STATES.pendingValidation, + hasValidated: false, + }; + store.setSession(session); + return session; +} + +export async function restoreSession(store) { + if (!store.session) { + return null; + } + + try { + await verifyConnection(store); + } catch (error) { + if (!isAuthFailure(error)) { + throw error; + } + } + + return store.session; +} + +export async function logout(store) { + try { + if (store.session?.applicationKey && store.session?.userLogin) { + await apiRequest(store, getPath('userApplication'), { + method: 'DELETE', + body: { + user: store.session.userLogin, + key: store.session.applicationKey, + application: TRYTON_APPLICATION, + }, + includeKitchen: false, + }); + } + } finally { + store.clearSessionState(); + } +} + +export async function verifyConnection(store) { + if (!store.session?.applicationKey) { + return null; + } + + try { + await listKitchens(store); + store.setSession({ + ...store.session, + state: CONNECTION_STATES.connected, + hasValidated: true, + }); + return store.session; + } catch (error) { + if (!isAuthFailure(error)) { + throw error; + } + + store.setSession({ + ...store.session, + state: store.session.hasValidated + ? CONNECTION_STATES.invalidKey + : CONNECTION_STATES.pendingValidation, + }); + + throw new Error( + store.session.hasValidated + ? 'The stored application key is no longer valid.' + : 'The application key is still waiting for validation in Tryton preferences.', + { + cause: error.cause || error, + }, + ); + } +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..09b241d --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,119 @@ +import { API_PATHS } from '../app/config.js'; + +function normalizeBaseUrl(baseUrl) { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) { + const cleanBaseUrl = normalizeBaseUrl(baseUrl); + const encodedDatabase = encodeURIComponent(database); + const encodedPath = path.replace(/^\/+/, ''); + const kitchenSegment = + includeKitchen && kitchenId + ? `/kitchen/${encodeURIComponent(String(kitchenId))}` + : ''; + + const url = new URL( + `${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`, + ); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + }); + + return url.toString(); +} + +async function parseResponse(response) { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + return response.json(); + } + + if (contentType.includes('image/')) { + return response.blob(); + } + + if (response.status === 204) { + return null; + } + + return response.text(); +} + +function normalizeError(response, payload) { + const message = + payload?.message || + payload?.error || + `Request failed with status ${response.status}.`; + + return new Error(message, { + cause: { + status: response.status, + details: payload?.errors || payload?.details || null, + }, + }); +} + +export async function apiRequest(store, path, options = {}) { + const { config, session, activeKitchen } = store; + + if (!config.baseUrl || !config.database) { + throw new Error('Server URL and database name are required.'); + } + + const headers = new Headers(options.headers || {}); + headers.set('Accept', options.accept || 'application/json'); + + if (options.body && !options.isFormData) { + headers.set('Content-Type', 'application/json'); + } + + if (session?.applicationKey) { + headers.set('Authorization', `Bearer ${session.applicationKey}`); + } + + const response = await fetch( + buildUrl({ + baseUrl: config.baseUrl, + database: config.database, + kitchenId: activeKitchen?.id, + path, + query: options.query, + includeKitchen: options.includeKitchen !== false, + }), + { + method: options.method || 'GET', + headers, + body: + options.body && !options.isFormData + ? JSON.stringify(options.body) + : options.body || undefined, + }, + ); + + const payload = await parseResponse(response); + if (!response.ok) { + throw normalizeError(response, payload); + } + + return payload; +} + +export function getPath(key) { + return API_PATHS[key]; +} + +export function buildKitchenApiUrl(store, path, query = {}) { + return buildUrl({ + baseUrl: store.config.baseUrl, + database: store.config.database, + kitchenId: store.activeKitchen?.id, + path, + query, + includeKitchen: true, + }); +} diff --git a/src/api/kitchens.js b/src/api/kitchens.js new file mode 100644 index 0000000..aba3bbf --- /dev/null +++ b/src/api/kitchens.js @@ -0,0 +1,12 @@ +import { apiRequest, getPath } from './client.js'; + +export async function listKitchens(store) { + const payload = await apiRequest(store, getPath('kitchens'), { + includeKitchen: false, + }); + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.kitchens || []; +} diff --git a/src/api/labels.js b/src/api/labels.js new file mode 100644 index 0000000..36fc865 --- /dev/null +++ b/src/api/labels.js @@ -0,0 +1,55 @@ +import { apiRequest, getPath } from './client.js'; + +export function normalizeLabelImagePayload(payload) { + if (!payload) { + return null; + } + + if (payload instanceof Blob) { + return { + objectUrl: URL.createObjectURL(payload), + contentType: payload.type, + }; + } + + if (payload?.imageUrl) { + return { + objectUrl: payload.imageUrl, + contentType: payload.contentType || 'image/png', + }; + } + + if (payload?.imageSvg) { + const blob = new Blob([payload.imageSvg], { type: 'image/svg+xml' }); + return { + objectUrl: URL.createObjectURL(blob), + contentType: 'image/svg+xml', + }; + } + + if (payload?.label) { + return { + objectUrl: `data:image/png;base64,${payload.label}`, + contentType: 'image/png', + }; + } + + return null; +} + +export async function previewLabel(store, body) { + const payload = await apiRequest(store, getPath('items'), { + method: 'POST', + body, + accept: 'image/svg+xml, image/png, application/json', + includeKitchen: false, + query: { label: 1, preview: 1 }, + }); + + const image = normalizeLabelImagePayload(payload); + if (image) { + return image; + } + + throw new Error('Label preview response did not include an image.'); +} diff --git a/src/api/locations.js b/src/api/locations.js new file mode 100644 index 0000000..a8259eb --- /dev/null +++ b/src/api/locations.js @@ -0,0 +1,37 @@ +import { apiRequest, getPath } from './client.js'; + +function flattenNodes(nodes, trail = [], lineage = []) { + return nodes.flatMap((node) => { + const currentTrail = [...trail, node.name]; + const currentLineage = [...lineage, node.uuid_b64]; + const current = { + id: node.id, + name: node.name, + type: node.type, + uuid: node.uuid, + uuid_b64: node.uuid_b64, + pathLabel: currentTrail.join(' / '), + depth: trail.length, + lineage_uuid_b64: currentLineage, + }; + + const children = Array.isArray(node.locations) + ? flattenNodes(node.locations, currentTrail, currentLineage) + : []; + + return [current, ...children]; + }); +} + +export async function fetchLocations(store) { + const payload = await apiRequest(store, getPath('locations'), { + includeKitchen: false, + }); + const tree = Array.isArray(payload) + ? payload + : payload?.data || payload?.locations || []; + return { + tree, + flat: flattenNodes(tree), + }; +} diff --git a/src/api/stock.js b/src/api/stock.js new file mode 100644 index 0000000..4ad38e3 --- /dev/null +++ b/src/api/stock.js @@ -0,0 +1,74 @@ +import { apiRequest, getPath } from './client.js'; + +export async function searchItemDefinitions(store, query) { + if (query.trim().length <= 2) { + return []; + } + + const payload = await apiRequest(store, getPath('items'), { + includeKitchen: false, + query: { search_name: query }, + }); + + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.items || []; +} + +export async function listStockEntries(store, filters = {}) { + const payload = await apiRequest(store, getPath('items'), { + includeKitchen: false, + }); + + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.entries || payload?.items || []; +} + +export async function getStockEntry(store, stockId) { + const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`); + return payload?.data || payload?.entry || payload; +} + +export async function createStockEntry(store, body) { + const payload = await apiRequest(store, getPath('items'), { + method: 'POST', + body, + includeKitchen: false, + query: { label: 1 }, + }); + return payload?.data || payload?.entry || payload; +} + +export async function updateStockItem(store, uuidB64, body) { + const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { + method: 'POST', + body, + includeKitchen: false, + }); + return payload?.data || payload?.entry || payload; +} + +export async function deleteStockItem(store, uuidB64) { + const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, { + method: 'DELETE', + includeKitchen: false, + }); + return payload?.data || payload?.entry || payload; +} + +export async function adjustStockEntry(store, stockId, body) { + const payload = await apiRequest( + store, + `${getPath('stockEntries')}/${stockId}/adjust`, + { + method: 'POST', + body, + }, + ); + return payload?.data || payload?.entry || payload; +} diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js new file mode 100644 index 0000000..cb86d6a --- /dev/null +++ b/src/app/bootstrap.js @@ -0,0 +1,91 @@ +import Alpine from 'alpinejs'; + +import { logout, restoreSession, verifyConnection } from '../api/auth.js'; +import { listKitchens } from '../api/kitchens.js'; +import { APP_NAME } from './config.js'; +import { createRouter, navigate } from './router.js'; +import { createAppStore } from './store.js'; +import { appShell } from '../components/app-shell.js'; +import { registerFeatureData } from '../features/register.js'; + +async function installServiceWorker() { + if ('serviceWorker' in navigator) { + await navigator.serviceWorker.register('/service-worker.js'); + } +} + +export function bootstrapApp() { + const store = createAppStore(); + Alpine.store('app', store); + + registerFeatureData(Alpine, store); + + const appRoot = document.querySelector('#app'); + appRoot.innerHTML = appShell(APP_NAME); + Alpine.initTree(appRoot); + + const router = createRouter({ + Alpine, + store, + outlet: document.querySelector('#route-view'), + }); + + window.__loncApp = { + navigate, + async refreshKitchens() { + const kitchens = await listKitchens(store); + store.setKitchens(kitchens); + if (!store.activeKitchen && kitchens.length) { + store.setActiveKitchen(kitchens[0]); + } + return kitchens; + }, + async restoreSession() { + try { + await restoreSession(store); + if (store.isConnected) { + await window.__loncApp.refreshKitchens(); + } + } catch (error) { + if (window.location.hash !== '#/login') { + navigate('/login'); + } + } + }, + async verifyConnection() { + await verifyConnection(store); + if (store.isConnected) { + await window.__loncApp.refreshKitchens(); + } + return store.session; + }, + async logout() { + await logout(store); + navigate('/login'); + }, + router, + }; + + window.addEventListener('online', () => { + store.addAlert({ type: 'success', message: 'Connection restored.' }); + }); + + window.addEventListener('offline', () => { + store.addAlert({ + type: 'warning', + message: 'You are offline. Cached screens stay available, but API actions may fail.', + }); + }); + + window.__loncApp + .restoreSession() + .finally(() => router.start()) + .catch(() => router.start()); + + installServiceWorker().catch(() => { + store.addAlert({ + type: 'warning', + message: 'PWA installation support could not be initialized.', + }); + }); +} diff --git a/src/app/config.js b/src/app/config.js new file mode 100644 index 0000000..76e3b0a --- /dev/null +++ b/src/app/config.js @@ -0,0 +1,39 @@ +export const APP_NAME = 'Lonc'; +export const TRYTON_APPLICATION = 'kitchen'; + +export const CONNECTION_STATES = { + notConnected: 'not_connected', + pendingValidation: 'pending_validation', + connected: 'connected', + invalidKey: 'invalid_key', +}; + +export const STORAGE_KEYS = { + appConfig: 'lonc.app.config', + session: 'lonc.auth.session', + activeKitchen: 'lonc.kitchen.active', + labelDraft: 'lonc.labels.draft', +}; + +export const DEFAULT_CONFIG = { + baseUrl: '', + database: '', +}; + +export const API_PATHS = { + userApplication: 'user/application/', + kitchens: 'kitchen/kitchens', + items: 'kitchen/items', + stockEntries: 'stock', + locations: 'kitchen/locations', +}; + +export const ROUTES = { + login: '/login', + home: '/', + stock: '/stock', + stockNew: '/stock/new', + stockDetail: '/stock/:id', + labelsNew: '/labels/new', + settings: '/settings', +}; diff --git a/src/app/router.js b/src/app/router.js new file mode 100644 index 0000000..dc11b80 --- /dev/null +++ b/src/app/router.js @@ -0,0 +1,95 @@ +import { ROUTES } from './config.js'; +import { renderDashboardPage } from '../features/dashboard/dashboard-page.js'; +import { renderKitchenSelector } from '../features/kitchens/kitchen-selector.js'; +import { renderLoginPage } from '../features/auth/login-page.js'; +import { renderLabelCreatePage } from '../features/labels/label-create-page.js'; +import { renderSettingsPage } from '../features/auth/settings-page.js'; +import { renderStockDetailPage } from '../features/stock/stock-detail-page.js'; +import { renderStockListPage } from '../features/stock/stock-list-page.js'; + +const routeDefinitions = [ + { path: ROUTES.login, render: renderLoginPage, protected: false }, + { path: ROUTES.home, render: renderDashboardPage, protected: true }, + { path: ROUTES.stock, render: renderStockListPage, protected: true }, + { path: ROUTES.stockNew, render: renderLabelCreatePage, protected: true }, + { path: ROUTES.stockDetail, render: renderStockDetailPage, protected: true }, + { path: ROUTES.labelsNew, render: renderLabelCreatePage, protected: true }, + { path: ROUTES.settings, render: renderSettingsPage, protected: false }, +]; + +function normalizeHashRoute() { + const route = window.location.hash.replace(/^#/, '') || ROUTES.home; + return route.startsWith('/') ? route : `/${route}`; +} + +function matchRoute(pathname) { + for (const definition of routeDefinitions) { + const keys = []; + const pattern = definition.path.replace(/:([^/]+)/g, (_, key) => { + keys.push(key); + return '([^/]+)'; + }); + + const regex = new RegExp(`^${pattern}$`); + const match = pathname.match(regex); + if (!match) { + continue; + } + + const params = keys.reduce((accumulator, key, index) => { + accumulator[key] = decodeURIComponent(match[index + 1]); + return accumulator; + }, {}); + + return { ...definition, params }; + } + + return null; +} + +export function navigate(path) { + window.location.hash = path; +} + +export function getRouteContext() { + return window.__loncRouteContext || { path: ROUTES.home, params: {} }; +} + +export function createRouter({ Alpine, store, outlet }) { + const render = async () => { + const pathname = normalizeHashRoute(); + const match = matchRoute(pathname); + + if (!match) { + navigate(ROUTES.home); + return; + } + + if (match.protected && !store.isConnected) { + navigate(ROUTES.login); + return; + } + + if ( + store.isConnected && + !store.activeKitchen && + pathname !== ROUTES.login && + pathname !== ROUTES.settings + ) { + outlet.innerHTML = renderKitchenSelector(); + Alpine.initTree(outlet); + return; + } + + window.__loncRouteContext = { path: pathname, params: match.params }; + outlet.innerHTML = match.render(); + Alpine.initTree(outlet); + }; + + window.addEventListener('hashchange', render); + + return { + start: render, + render, + }; +} diff --git a/src/app/store.js b/src/app/store.js new file mode 100644 index 0000000..45755ce --- /dev/null +++ b/src/app/store.js @@ -0,0 +1,104 @@ +import { + CONNECTION_STATES, + DEFAULT_CONFIG, + STORAGE_KEYS, + TRYTON_APPLICATION, +} from './config.js'; +import { + clearStoredValue, + loadStoredValue, + saveStoredValue, +} from '../features/shared/storage.js'; + +const defaultState = { + config: { ...DEFAULT_CONFIG }, + session: null, + kitchens: [], + activeKitchen: null, + alerts: [], +}; + +export function createAppStore() { + const state = { + ...defaultState, + config: loadStoredValue(STORAGE_KEYS.appConfig, { ...DEFAULT_CONFIG }), + session: loadStoredValue(STORAGE_KEYS.session, null), + activeKitchen: loadStoredValue(STORAGE_KEYS.activeKitchen, null), + alerts: [], + }; + + return { + ...state, + get isAuthenticated() { + return Boolean(this.session?.applicationKey); + }, + get isConnected() { + return this.session?.state === CONNECTION_STATES.connected; + }, + get needsValidation() { + return this.session?.state === CONNECTION_STATES.pendingValidation; + }, + setConfig(nextConfig) { + this.config = { ...this.config, ...nextConfig }; + saveStoredValue(STORAGE_KEYS.appConfig, this.config); + }, + setSession(session) { + this.session = session + ? { + application: TRYTON_APPLICATION, + state: CONNECTION_STATES.notConnected, + hasValidated: false, + ...session, + } + : null; + + if (session) { + saveStoredValue(STORAGE_KEYS.session, this.session); + return; + } + + clearStoredValue(STORAGE_KEYS.session); + }, + setKitchens(kitchens) { + this.kitchens = kitchens; + if ( + this.activeKitchen && + !kitchens.some((kitchen) => kitchen.id === this.activeKitchen.id) + ) { + this.activeKitchen = null; + clearStoredValue(STORAGE_KEYS.activeKitchen); + } + }, + setActiveKitchen(kitchen) { + this.activeKitchen = kitchen; + if (kitchen) { + saveStoredValue(STORAGE_KEYS.activeKitchen, kitchen); + return; + } + + clearStoredValue(STORAGE_KEYS.activeKitchen); + }, + addAlert(alert) { + const nextAlert = { + id: crypto.randomUUID(), + type: 'info', + timeout: 5000, + ...alert, + }; + + this.alerts = [...this.alerts, nextAlert]; + + if (nextAlert.timeout) { + window.setTimeout(() => this.removeAlert(nextAlert.id), nextAlert.timeout); + } + }, + removeAlert(alertId) { + this.alerts = this.alerts.filter((alert) => alert.id !== alertId); + }, + clearSessionState() { + this.setSession(null); + this.setKitchens([]); + this.setActiveKitchen(null); + }, + }; +} diff --git a/src/components/app-shell.js b/src/components/app-shell.js new file mode 100644 index 0000000..916a143 --- /dev/null +++ b/src/components/app-shell.js @@ -0,0 +1,28 @@ +import { navBar } from './nav-bar.js'; + +export function appShell(appName) { + return ` +
+ ${navBar(appName)} +
+
+ +
+
+ `; +} diff --git a/src/components/confirm-dialog.js b/src/components/confirm-dialog.js new file mode 100644 index 0000000..a677f69 --- /dev/null +++ b/src/components/confirm-dialog.js @@ -0,0 +1,19 @@ +export function confirmDialog(dialogId, title, body, actionLabel) { + return ` + + `; +} diff --git a/src/components/empty-state.js b/src/components/empty-state.js new file mode 100644 index 0000000..230ec6e --- /dev/null +++ b/src/components/empty-state.js @@ -0,0 +1,10 @@ +export function emptyState(title, body) { + return ` +
+
+

${title}

+

${body}

+
+
+ `; +} diff --git a/src/components/error-state.js b/src/components/error-state.js new file mode 100644 index 0000000..d723a38 --- /dev/null +++ b/src/components/error-state.js @@ -0,0 +1,7 @@ +export function errorState(message) { + return ` + + `; +} diff --git a/src/components/loading-state.js b/src/components/loading-state.js new file mode 100644 index 0000000..ef5c28e --- /dev/null +++ b/src/components/loading-state.js @@ -0,0 +1,8 @@ +export function loadingState(message = 'Loading...') { + return ` +
+ +

${message}

+
+ `; +} diff --git a/src/components/nav-bar.js b/src/components/nav-bar.js new file mode 100644 index 0000000..3873185 --- /dev/null +++ b/src/components/nav-bar.js @@ -0,0 +1,34 @@ +export function navBar(appName) { + return ` + + `; +} diff --git a/src/features/auth/login-page.js b/src/features/auth/login-page.js new file mode 100644 index 0000000..ff2c1ca --- /dev/null +++ b/src/features/auth/login-page.js @@ -0,0 +1,225 @@ +import { login, verifyConnection } from '../../api/auth.js'; +import { CONNECTION_STATES } from '../../app/config.js'; +import { navigate } from '../../app/router.js'; +import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; + +export function renderLoginPage() { + return ` +
+
+
+
+
+
+

Kitchen User Application

+

Connect Lonc to Tryton

+

+ Lonc uses a Tryton user application key for kitchen, not a normal session login. +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ This requests a pending application key that must be approved in Tryton client preferences. +
+
+ + + + +
+ + +
+
+
+
+
+ `; +} + +export function loginPageData(store) { + return { + store, + state: createAsyncState(), + verifyState: createAsyncState(), + sessionState: store.session?.state || CONNECTION_STATES.notConnected, + applicationKey: store.session?.applicationKey || '', + form: { + baseUrl: store.config.baseUrl || '', + database: store.config.database || '', + userLogin: store.session?.userLogin || '', + }, + get hasStoredKey() { + return Boolean(this.applicationKey); + }, + init() { + this.syncFromStore(); + if (store.isConnected) { + navigate('/'); + return; + } + + if (this.hasStoredKey) { + this.tryAutoVerify(); + } + }, + async submit() { + await runAsyncState(this.state, async () => { + this.verifyState.error = ''; + store.setConfig({ + baseUrl: this.form.baseUrl.trim(), + database: this.form.database.trim(), + }); + + await login(store, { + userLogin: this.form.userLogin.trim(), + }); + this.syncFromStore(); + + store.addAlert({ + type: 'info', + message: `Got secret key starting ${store.session.applicationKey.slice(0, 8)}.... Approve this key in Tryton preferences.`, + }); + }); + }, + async checkConnection() { + await runAsyncState(this.verifyState, async () => { + await window.__loncApp.verifyConnection(); + this.syncFromStore(); + if (store.isConnected) { + store.addAlert({ type: 'success', message: 'Kitchen application key is active.' }); + navigate('/'); + } + }).catch(() => { + this.verifyState.error = + 'Failed to verify connection. Please verify the application key in Tryton first.'; + }); + }, + async tryAutoVerify() { + await runAsyncState(this.verifyState, async () => { + try { + await verifyConnection(store); + } catch { + this.syncFromStore(); + return; + } + + this.syncFromStore(); + if (store.isConnected) { + await window.__loncApp.refreshKitchens(); + navigate('/'); + } + }).catch(() => {}); + }, + async disconnect() { + await window.__loncApp.logout(); + this.syncFromStore(); + this.sessionState = CONNECTION_STATES.notConnected; + this.applicationKey = ''; + this.verifyState.error = ''; + }, + statusTitle() { + return { + [CONNECTION_STATES.pendingValidation]: 'Validation still pending', + [CONNECTION_STATES.connected]: 'Connected', + [CONNECTION_STATES.invalidKey]: 'Stored key is invalid', + }[this.sessionState] || 'Not connected'; + }, + statusMessage() { + return { + [CONNECTION_STATES.pendingValidation]: + 'The application key was created successfully. Copy or compare it with the pending key in Tryton client preferences, approve it there, then verify the connection here.', + [CONNECTION_STATES.connected]: + 'The kitchen user application key is active and ready for protected requests.', + [CONNECTION_STATES.invalidKey]: + 'This key worked before but is no longer accepted. Disconnect and create a new one.', + }[this.sessionState] || 'Create a new application key to connect.'; + }, + statusAlertClass() { + return { + [CONNECTION_STATES.pendingValidation]: 'alert-warning', + [CONNECTION_STATES.connected]: 'alert-success', + [CONNECTION_STATES.invalidKey]: 'alert-danger', + }[this.sessionState] || 'alert-secondary'; + }, + maskedApplicationKey() { + const key = this.applicationKey; + return key ? `${key.slice(0, 16)}...` : ''; + }, + syncFromStore() { + this.sessionState = store.session?.state || CONNECTION_STATES.notConnected; + this.applicationKey = store.session?.applicationKey || ''; + this.form.userLogin = store.session?.userLogin || this.form.userLogin; + }, + }; +} diff --git a/src/features/auth/settings-page.js b/src/features/auth/settings-page.js new file mode 100644 index 0000000..db6a957 --- /dev/null +++ b/src/features/auth/settings-page.js @@ -0,0 +1,65 @@ +export function renderSettingsPage() { + return ` +
+
+
+
+
+
+
+

Client Settings

+

Connection & workspace

+

+ These values are stored locally and reused to start the Tryton user application flow. +

+
+
+ +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+

Integration notes

+
    +
  • Connection uses Tryton user application keys for the kitchen application.
  • +
  • Kitchen-scoped requests are built as /{database}/kitchen/{kitchenId}/....
  • +
  • Label preview accepts image blobs, image URLs, or SVG payloads.
  • +
+
+
+
+
+
+ `; +} + +export function settingsPageData(store) { + return { + form: { + baseUrl: store.config.baseUrl || '', + database: store.config.database || '', + }, + save() { + store.setConfig({ + baseUrl: this.form.baseUrl.trim(), + database: this.form.database.trim(), + }); + + store.addAlert({ type: 'success', message: 'Settings saved locally.' }); + }, + }; +} diff --git a/src/features/dashboard/dashboard-page.js b/src/features/dashboard/dashboard-page.js new file mode 100644 index 0000000..a78cfc2 --- /dev/null +++ b/src/features/dashboard/dashboard-page.js @@ -0,0 +1,96 @@ +export function renderDashboardPage() { + return ` +
+
+
+
+

Kitchen stock management

+

Keep labels, stock, and adjustments in one focused workflow.

+

+ This MVP is shaped for fast household operations on a phone or desktop, with the Tryton backend staying in charge of business logic. +

+ +
+
+
+
Active kitchen
+
+
+ Switch without signing out when you need to work across kitchens in the same Tryton database. +
+ +
+
+
+
+ + + + +
+ `; +} + +export function dashboardPageData(store) { + return { + showKitchenPicker: false, + setKitchen(kitchen) { + store.setActiveKitchen(kitchen); + this.showKitchenPicker = false; + store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` }); + }, + }; +} diff --git a/src/features/kitchens/kitchen-selector.js b/src/features/kitchens/kitchen-selector.js new file mode 100644 index 0000000..4f49139 --- /dev/null +++ b/src/features/kitchens/kitchen-selector.js @@ -0,0 +1,73 @@ +export function renderKitchenSelector() { + return ` +
+
+
+
+
+

Kitchen Context

+

Pick the active kitchen

+

+ Every stock, label, and location request uses this kitchen context until you switch. +

+ + + + + + + +
+ +
+
+
+
+
+
+ `; +} + +export function kitchenSelectorData(store) { + return { + state: { + isLoading: false, + error: '', + }, + get kitchens() { + return store.kitchens; + }, + async init() { + if (!store.kitchens.length) { + try { + this.state.isLoading = true; + await window.__loncApp.refreshKitchens(); + } catch (error) { + this.state.error = error.message; + } finally { + this.state.isLoading = false; + } + } + }, + select(kitchen) { + store.setActiveKitchen(kitchen); + store.addAlert({ type: 'success', message: `Active kitchen set to ${kitchen.name}.` }); + window.__loncApp.router.render(); + }, + }; +} diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js new file mode 100644 index 0000000..a34076b --- /dev/null +++ b/src/features/labels/label-create-page.js @@ -0,0 +1,727 @@ +import { createStockEntry, searchItemDefinitions } from '../../api/stock.js'; +import { fetchLocations } from '../../api/locations.js'; +import { previewLabel } from '../../api/labels.js'; +import { STORAGE_KEYS } from '../../app/config.js'; +import { debounce, normalizeValidationError } from '../shared/form-utils.js'; +import { loadStoredValue, saveStoredValue } from '../shared/storage.js'; +import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; + +const STOCK_TYPE_OPTIONS = [ + { value: 'measured', label: 'Measured' }, + { value: 'descriptive', label: 'Descriptive' }, + { value: 'binary', label: 'Binary' }, +]; + +const STOCK_LEVEL_OPTIONS = [ + { value: 'plenty', label: 'Plenty (> 75%)' }, + { value: 'good', label: 'Good (> 50%)' }, + { value: 'some', label: 'Some (> 25%)' }, + { value: 'low', label: 'Low (> 10%)' }, + { value: 'trace', label: 'Trace (<= 10%)' }, + { value: 'gone', label: 'Gone (~= 0%)' }, +]; + +export function renderLabelCreatePage() { + return ` +
+
+
+

Label Creation

+

Create a stock label and entry

+

+ Active kitchen: + +

+
+
+ Drafts are stored locally so small navigation changes do not wipe form input. +
+
+ +
+
+
+
+
+
+ + + + +
+ +
+
+ + + +
+
+ + + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
Amount
+
+
+
Unit
+
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
Value
+
+
+
Unit
+
+
+
+
+ +
+ + + + +
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
Days
+
+
+
Date
+
+
+
Enter either days or a date. The other field updates automatically.
+
+
+ + + + + +
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+

Label preview

+

PNG and SVG responses are both supported.

+
+
+ + + + +
+
+
+
+
+ `; +} + +function todayIsoDate() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function addDaysToIsoDate(isoDate, days) { + const [year, month, day] = isoDate.split('-').map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() + days); + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, '0'); + const nextDay = String(date.getDate()).padStart(2, '0'); + return `${nextYear}-${nextMonth}-${nextDay}`; +} + +function diffDays(fromIsoDate, toIsoDate) { + const [fromYear, fromMonth, fromDay] = fromIsoDate.split('-').map(Number); + const [toYear, toMonth, toDay] = toIsoDate.split('-').map(Number); + const fromDate = new Date(fromYear, fromMonth - 1, fromDay); + const toDate = new Date(toYear, toMonth - 1, toDay); + const millisecondsPerDay = 24 * 60 * 60 * 1000; + return Math.round((toDate - fromDate) / millisecondsPerDay); +} + +function createDefaultForm() { + return { + itemId: '', + search: '', + name: '', + description: '', + quantity: '', + uom: 'g', + stockType: 'binary', + level: 'plenty', + energy: '', + energyUnit: 'kcal (100g/ml)', + productionDate: todayIsoDate(), + expireDays: '', + expirationDate: '', + locationId: '', + }; +} + +function loadLabelDraft() { + const draft = loadStoredValue(STORAGE_KEYS.labelDraft, createDefaultForm()); + + return { + ...createDefaultForm(), + ...draft, + itemId: '', + search: '', + }; +} + +function buildDraftPayload(form) { + return { + ...form, + itemId: '', + search: '', + }; +} + +export function labelCreatePageData(store) { + return { + previewState: createAsyncState(), + createState: createAsyncState(), + stockTypeOptions: STOCK_TYPE_OPTIONS, + stockLevelOptions: STOCK_LEVEL_OPTIONS, + suggestions: [], + locations: [], + locationSearch: '', + locationPickerOpen: false, + previewUrl: '', + successMessage: '', + submitError: '', + fieldErrors: {}, + form: { + ...loadLabelDraft(), + }, + async init() { + await this.loadLocations(); + this.$watch('form', () => this.persistDraft(), { deep: true }); + this.$watch('form.stockType', (value) => { + this.syncStockTypeState(value); + this.syncStockTypeSelect(); + this.syncStockLevelSelect(); + }); + this.$watch('form.level', () => this.syncStockLevelSelect()); + this.syncStockTypeState(this.form.stockType); + this.syncStockTypeSelect(); + this.syncStockLevelSelect(); + this.searchDebounced = debounce(async () => { + if (this.form.search.trim().length <= 2) { + this.suggestions = []; + return; + } + + this.suggestions = await searchItemDefinitions(store, this.form.search.trim()); + }, 250); + }, + async loadLocations() { + try { + const { flat } = await fetchLocations(store); + this.locations = flat; + this.syncLocationSelection(); + } catch (error) { + store.addAlert({ + type: 'warning', + message: `Locations could not be loaded: ${error.message}`, + }); + } + }, + onSearchInput() { + this.persistDraft(); + this.searchDebounced(); + }, + pickSuggestion(item) { + this.form.itemId = item.id; + this.form.search = item.name; + this.form.name = item.name; + this.form.description = item.description || this.form.description; + this.form.uom = item.uom_symbol || this.form.uom; + this.form.quantity = + item.quantity === 0 || item.quantity + ? String(item.quantity) + : this.form.quantity; + this.form.stockType = item.stock_type || this.form.stockType; + this.form.level = item.level || this.form.level; + this.form.expirationDate = item.expire_date || this.form.expirationDate; + this.applyItemLocation(item.location_initial_uuid_b64); + this.syncExpireDaysFromDate(); + this.suggestions = []; + this.persistDraft(); + }, + clearItemSearch() { + this.form.itemId = ''; + this.form.search = ''; + this.suggestions = []; + this.persistDraft(); + }, + persistDraft() { + saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); + }, + get filteredLocations() { + const query = this.locationSearch.trim().toLowerCase(); + const selectedLabel = this.selectedLocation + ? this.selectedLocation.name.toLowerCase() + : ''; + + if (selectedLabel && query === selectedLabel) { + return this.locations; + } + + if (!query) { + return this.locations; + } + + return this.locations.filter((location) => + `${location.name} ${location.pathLabel}`.toLowerCase().includes(query), + ); + }, + get selectedLocation() { + return this.locations.find( + (location) => String(location.id) === String(this.form.locationId), + ) || null; + }, + get selectedLocationPath() { + return this.selectedLocation?.pathLabel || ''; + }, + pickLocation(location) { + this.form.locationId = String(location.id); + this.locationSearch = location.name; + this.locationPickerOpen = false; + this.persistDraft(); + }, + clearLocation() { + this.form.locationId = ''; + this.locationSearch = ''; + this.locationPickerOpen = true; + this.persistDraft(); + }, + openLocationPicker() { + this.locationPickerOpen = true; + }, + onLocationInput() { + this.locationPickerOpen = true; + if (this.selectedLocation && this.locationSearch !== this.selectedLocation.name) { + this.form.locationId = ''; + } + }, + handleLocationFocusOut(event) { + const nextTarget = event.relatedTarget; + if (nextTarget && this.$refs.locationPicker?.contains(nextTarget)) { + return; + } + + this.locationPickerOpen = false; + }, + syncLocationSelection() { + if (!this.form.locationId) { + this.locationSearch = ''; + return; + } + + const selected = this.locations.find( + (location) => String(location.id) === String(this.form.locationId), + ); + + this.locationSearch = selected ? selected.name : ''; + }, + applyItemLocation(locationUuidB64) { + if (!locationUuidB64) { + return; + } + + const location = this.locations.find( + (entry) => entry.uuid_b64 === locationUuidB64, + ); + + if (!location) { + return; + } + + this.form.locationId = String(location.id); + this.locationSearch = location.name; + }, + syncExpireDateFromDays() { + if (this.form.expireDays === '') { + return; + } + + const days = Number(this.form.expireDays); + if (Number.isNaN(days) || days < 0) { + return; + } + + const baseDate = this.form.productionDate || todayIsoDate(); + this.form.expirationDate = addDaysToIsoDate(baseDate, days); + }, + syncExpireDaysFromDate() { + if (!this.form.expirationDate) { + this.form.expireDays = ''; + return; + } + + const baseDate = this.form.productionDate || todayIsoDate(); + const days = diffDays(baseDate, this.form.expirationDate); + this.form.expireDays = days >= 0 ? String(days) : ''; + }, + clearExpirationDate() { + this.form.expirationDate = ''; + this.form.expireDays = ''; + }, + clearEnergyFields() { + this.form.energy = ''; + this.form.energyUnit = 'kcal (100g/ml)'; + }, + clearQuantityFields() { + this.form.quantity = ''; + this.form.uom = 'g'; + }, + syncStockTypeState(stockType) { + if (stockType === 'measured') { + this.form.level = ''; + if (this.form.uom === '') { + this.form.uom = 'g'; + } + return; + } + + this.form.quantity = ''; + this.form.uom = 'g'; + + if (stockType === 'binary' && this.form.level !== 'plenty') { + this.form.level = 'plenty'; + return; + } + + if (stockType === 'descriptive' && (!this.form.level || this.form.level === '')) { + this.form.level = 'plenty'; + } + }, + syncStockTypeSelect() { + if (this.$refs.stockTypeSelect) { + this.$refs.stockTypeSelect.value = this.form.stockType; + } + }, + syncStockLevelSelect() { + if (this.$refs.stockLevelSelect) { + this.$refs.stockLevelSelect.value = this.form.level || 'plenty'; + } + }, + buildPayload() { + const quantity = + this.form.stockType === 'measured' + ? this.form.quantity === '' + ? null + : Number(this.form.quantity) + : this.form.stockType === 'binary' + ? 1 + : null; + + return { + item_id: this.form.itemId || null, + name: this.form.name.trim(), + description: this.form.description.trim(), + quantity_initial: quantity, + uom_symbol: this.form.stockType === 'measured' ? this.form.uom.trim() : null, + calories: this.form.energy === '' ? null : Number(this.form.energy), + calories_unit: this.form.energyUnit.trim() || null, + stock_type: this.form.stockType, + level: this.form.stockType === 'measured' ? null : this.form.level || null, + date: this.form.productionDate || null, + expire_date: this.form.expirationDate || null, + location_initial: this.form.locationId || null, + kitchen_id: store.activeKitchen?.id || null, + }; + }, + async preview() { + await runAsyncState(this.previewState, async () => { + this.successMessage = ''; + const result = await previewLabel(store, this.buildPayload()); + if (this.previewUrl && this.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(this.previewUrl); + } + this.previewUrl = result.objectUrl; + this.persistDraft(); + }); + }, + async create() { + this.submitError = ''; + this.fieldErrors = {}; + + await runAsyncState(this.createState, async () => { + try { + const entry = await createStockEntry(store, this.buildPayload()); + if (this.previewUrl && this.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(this.previewUrl); + } + this.previewUrl = ''; + this.successMessage = `${entry.name || this.form.name} was created successfully.`; + store.addAlert({ + type: 'success', + message: `${entry.name || this.form.name} was created successfully.`, + }); + saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); + } catch (error) { + this.fieldErrors = normalizeValidationError(error.cause); + this.submitError = error.message; + throw error; + } + }).catch(() => {}); + }, + reset(revokePreview = true) { + this.form = createDefaultForm(); + this.syncStockTypeState(this.form.stockType); + this.suggestions = []; + this.locationSearch = ''; + this.locationPickerOpen = false; + this.successMessage = ''; + this.submitError = ''; + this.fieldErrors = {}; + saveStoredValue(STORAGE_KEYS.labelDraft, this.form); + if (revokePreview && this.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(this.previewUrl); + } + this.previewUrl = ''; + }, + locationItemStyle(location) { + const depth = location.depth || 0; + return `padding-left: calc(1rem + ${depth} * 1rem);`; + }, + locationLevelLabel(location) { + return `L${(location.depth || 0) + 1}`; + }, + locationPathHint(location) { + if (!location.depth) { + return location.type || 'Top-level location'; + } + + const segments = location.pathLabel.split(' / '); + return `Inside ${segments.slice(0, -1).join(' / ')}`; + }, + }; +} diff --git a/src/features/register.js b/src/features/register.js new file mode 100644 index 0000000..128b58c --- /dev/null +++ b/src/features/register.js @@ -0,0 +1,19 @@ +import { alertsData } from './shared/alerts.js'; +import { loginPageData } from './auth/login-page.js'; +import { settingsPageData } from './auth/settings-page.js'; +import { dashboardPageData } from './dashboard/dashboard-page.js'; +import { kitchenSelectorData } from './kitchens/kitchen-selector.js'; +import { labelCreatePageData } from './labels/label-create-page.js'; +import { stockDetailPageData } from './stock/stock-detail-page.js'; +import { stockListPageData } from './stock/stock-list-page.js'; + +export function registerFeatureData(Alpine, store) { + Alpine.data('alertsData', () => alertsData(store)); + Alpine.data('loginPage', () => loginPageData(store)); + Alpine.data('settingsPage', () => settingsPageData(store)); + Alpine.data('dashboardPage', () => dashboardPageData(store)); + Alpine.data('kitchenSelector', () => kitchenSelectorData(store)); + Alpine.data('labelCreatePage', () => labelCreatePageData(store)); + Alpine.data('stockListPage', () => stockListPageData(store)); + Alpine.data('stockDetailPage', () => stockDetailPageData(store)); +} diff --git a/src/features/shared/alerts.js b/src/features/shared/alerts.js new file mode 100644 index 0000000..3799ed6 --- /dev/null +++ b/src/features/shared/alerts.js @@ -0,0 +1,18 @@ +export function alertsData(store) { + return { + get alerts() { + return store.alerts; + }, + dismiss(alertId) { + store.removeAlert(alertId); + }, + badgeClass(type) { + return { + info: 'bg-info-subtle text-info-emphasis', + success: 'bg-success-subtle text-success-emphasis', + warning: 'bg-warning-subtle text-warning-emphasis', + danger: 'bg-danger-subtle text-danger-emphasis', + }[type] || 'bg-secondary-subtle text-secondary-emphasis'; + }, + }; +} diff --git a/src/features/shared/date-utils.js b/src/features/shared/date-utils.js new file mode 100644 index 0000000..3b2da3d --- /dev/null +++ b/src/features/shared/date-utils.js @@ -0,0 +1,16 @@ +export function formatDate(value) { + if (!value) { + return 'Not set'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); +} diff --git a/src/features/shared/form-utils.js b/src/features/shared/form-utils.js new file mode 100644 index 0000000..3711e53 --- /dev/null +++ b/src/features/shared/form-utils.js @@ -0,0 +1,20 @@ +export function fieldError(errors, field) { + return errors[field] || ''; +} + +export function normalizeValidationError(error) { + if (error?.details && typeof error.details === 'object') { + return error.details; + } + + return {}; +} + +export function debounce(callback, wait = 300) { + let timeoutId; + + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => callback(...args), wait); + }; +} diff --git a/src/features/shared/storage.js b/src/features/shared/storage.js new file mode 100644 index 0000000..8c33553 --- /dev/null +++ b/src/features/shared/storage.js @@ -0,0 +1,24 @@ +export function loadStoredValue(key, fallback = null) { + try { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch { + return fallback; + } +} + +export function saveStoredValue(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage failures so the app still works in constrained browsers. + } +} + +export function clearStoredValue(key) { + try { + window.localStorage.removeItem(key); + } catch { + // Ignore storage failures so the app still works in constrained browsers. + } +} diff --git a/src/features/shared/ui-state.js b/src/features/shared/ui-state.js new file mode 100644 index 0000000..7aad68b --- /dev/null +++ b/src/features/shared/ui-state.js @@ -0,0 +1,20 @@ +export function createAsyncState() { + return { + isLoading: false, + error: '', + }; +} + +export async function runAsyncState(target, callback) { + target.isLoading = true; + target.error = ''; + + try { + return await callback(); + } catch (error) { + target.error = error.message || 'Something went wrong.'; + throw error; + } finally { + target.isLoading = false; + } +} diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js new file mode 100644 index 0000000..a22765a --- /dev/null +++ b/src/features/stock/stock-detail-page.js @@ -0,0 +1,133 @@ +import { adjustStockEntry, getStockEntry } from '../../api/stock.js'; +import { getRouteContext } from '../../app/router.js'; +import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; +import { formatDate } from '../shared/date-utils.js'; + +export function renderStockDetailPage() { + return ` +
+
+
+ ← Back to stock +

Stock detail

+

Inspect the entry and update its quantity without leaving the workflow.

+
+
+ + + + +
+ `; +} + +export function stockDetailPageData(store) { + return { + state: createAsyncState(), + adjustmentState: createAsyncState(), + entry: null, + adjustment: { + mode: 'increment', + quantity: '1', + }, + async init() { + const { params } = getRouteContext(); + await runAsyncState(this.state, async () => { + this.entry = await getStockEntry(store, params.id); + }).catch(() => {}); + }, + async submitAdjustment() { + if (!this.entry) { + return; + } + + await runAsyncState(this.adjustmentState, async () => { + this.entry = await adjustStockEntry(store, this.entry.id, { + mode: this.adjustment.mode, + quantity: Number(this.adjustment.quantity), + }); + store.addAlert({ type: 'success', message: 'Stock quantity updated.' }); + }).catch(() => {}); + }, + quickAdjust(step) { + const current = Number(this.adjustment.quantity || 0); + this.adjustment.quantity = String(Math.max(current + step, 0)); + }, + formatDate, + formatQuantity(entry) { + return `${entry.quantity ?? 0} ${entry.uom || ''}`.trim(); + }, + }; +} diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js new file mode 100644 index 0000000..25d7cd5 --- /dev/null +++ b/src/features/stock/stock-list-page.js @@ -0,0 +1,620 @@ +import { deleteStockItem, listStockEntries, updateStockItem } from '../../api/stock.js'; +import { fetchLocations } from '../../api/locations.js'; +import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; +import { formatDate } from '../shared/date-utils.js'; + +const LEVEL_LABELS = { + plenty: 'Plenty', + good: 'Good', + some: 'Some', + low: 'Low', + trace: 'Trace', + gone: 'Gone', +}; + +const LEVEL_OPTIONS = [ + { value: 'plenty', label: 'Plenty' }, + { value: 'good', label: 'Good' }, + { value: 'some', label: 'Some' }, + { value: 'low', label: 'Low' }, + { value: 'trace', label: 'Trace' }, + { value: 'gone', label: 'Gone' }, +]; + +const EXPIRATION_LEGEND = [ + { key: 'expired', label: 'Expired', description: 'The expiration date has already passed.' }, + { key: 'use-first', label: 'Use first', description: 'Still within date, but should be prioritized soonest for consumption.' }, + { key: 'upcoming', label: 'Upcoming expiration', description: 'Within date, but approaching expiration in the near term.' }, + { key: 'within-date', label: 'Within date', description: 'Still within the expected shelf-life window.' }, + { key: 'none', label: 'No expiration', description: 'No expiration date is assigned.' }, +]; + +function todayAtMidnight() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function parseDateValue(value) { + if (!value) { + return null; + } + + const [year, month, day] = value.split('-').map(Number); + if (!year || !month || !day) { + return null; + } + + return new Date(year, month - 1, day); +} + +function expirationInfo(entry) { + if (!entry.expire_date) { + return { + key: 'none', + label: 'No expiration date', + detail: 'No expiration date', + sortRank: 4, + }; + } + + const today = todayAtMidnight(); + const expireDate = parseDateValue(entry.expire_date); + const expireIn = + typeof entry.expire_in === 'number' + ? entry.expire_in + : Math.round((expireDate - today) / (24 * 60 * 60 * 1000)); + + if (expireIn < 0) { + return { + key: 'expired', + label: 'Expired', + detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`, + sortRank: 0, + }; + } + + if (expireIn <= 2) { + return { + key: 'use-first', + label: expireIn === 0 ? 'Use today' : 'Use first', + detail: + expireIn === 0 + ? 'Expires today' + : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`, + sortRank: 1, + }; + } + + if (expireIn <= 7) { + return { + key: 'upcoming', + label: 'Upcoming expiration', + detail: `Expires in ${expireIn} days`, + sortRank: 2, + }; + } + + return { + key: 'within-date', + label: 'Within date', + detail: `Expires in ${expireIn} days`, + sortRank: 3, + }; +} + +function sortEntries(entries) { + return [...entries].sort((left, right) => { + const leftExpiration = expirationInfo(left); + const rightExpiration = expirationInfo(right); + + if (leftExpiration.sortRank !== rightExpiration.sortRank) { + return leftExpiration.sortRank - rightExpiration.sortRank; + } + + const leftExpire = left.expire_date || '9999-12-31'; + const rightExpire = right.expire_date || '9999-12-31'; + if (leftExpire !== rightExpire) { + return leftExpire.localeCompare(rightExpire); + } + + return (left.name || '').localeCompare(right.name || ''); + }); +} + +function quantityLabel(entry) { + if (entry.stock_type === 'binary') { + return entry.level === 'gone' ? 'Gone' : 'Available'; + } + + const numeric = entry.quantity ?? null; + const uom = entry.uom_symbol || ''; + const measured = numeric !== null && numeric !== undefined ? `${numeric} ${uom}`.trim() : ''; + const level = entry.level ? LEVEL_LABELS[entry.level] || entry.level : ''; + + if (entry.stock_type === 'descriptive') { + return level || 'No stock level'; + } + + if (measured && level) { + return `${measured} • ${level}`; + } + + return measured || level || 'No quantity'; +} + +function resolveLocationLabel(entry, locationMap) { + if (!entry.location_initial_uuid_b64) { + return 'No location assigned'; + } + + return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved'; +} + +function searchBlob(entry, locationMap) { + return [ + entry.name, + entry.description, + entry.level, + entry.stock_type, + resolveLocationLabel(entry, locationMap), + entry.uuid_b64, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} + +export function renderStockListPage() { + return ` +
+
+
+

Stock Review

+

Review stock and act quickly

+

+ Focus on expiration, stock state, and location without leaving the overview. +

+
+ New stock label +
+ +
+
+
+
+ + +
+
+
+
+ More filters +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+ +
+ +
+

Expiration overview

+

+ Show what each expiration color means. +

+
+
+ + item(s) visible +
+
+
+
+ +
+
+
+ + + + + + + + +
+ `; +} + +export function stockListPageData(store) { + return { + state: createAsyncState(), + entries: [], + locations: [], + locationMap: {}, + locationDescendants: {}, + editForms: {}, + editErrors: {}, + levelOptions: LEVEL_OPTIONS, + expirationLegend: EXPIRATION_LEGEND, + filters: { + search: '', + expiration: '', + location: '', + }, + async init() { + await Promise.all([this.loadLocations(), this.loadEntries()]); + }, + async loadEntries() { + await runAsyncState(this.state, async () => { + const loadedEntries = await listStockEntries(store); + this.entries = sortEntries(loadedEntries); + this.editForms = Object.fromEntries( + this.entries.map((entry) => [ + entry.id, + { + level: entry.level || 'plenty', + quantity: entry.quantity ?? '', + }, + ]), + ); + this.editErrors = {}; + }).catch(() => {}); + }, + async loadLocations() { + try { + const { flat } = await fetchLocations(store); + this.locations = flat; + this.locationMap = Object.fromEntries( + flat.map((location) => [location.uuid_b64, location.pathLabel]), + ); + this.locationDescendants = Object.fromEntries( + flat.map((location) => [ + location.uuid_b64, + flat + .filter((candidate) => candidate.lineage_uuid_b64.includes(location.uuid_b64)) + .map((candidate) => candidate.uuid_b64), + ]), + ); + } catch { + this.locations = []; + this.locationMap = {}; + this.locationDescendants = {}; + } + }, + clearFilters() { + this.filters = { + search: '', + expiration: '', + location: '', + }; + }, + get filteredEntries() { + return this.entries.filter((entry) => { + if ( + this.filters.search && + !searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase()) + ) { + return false; + } + + if ( + this.filters.expiration && + expirationInfo(entry).key !== this.filters.expiration + ) { + return false; + } + + if ( + this.filters.location && + !this.locationMatchesFilter(entry.location_initial_uuid_b64, this.filters.location) + ) { + return false; + } + + return true; + }); + }, + expirationFor(entry) { + return expirationInfo(entry); + }, + rowClass(entry) { + return `expiration-${expirationInfo(entry).key}`; + }, + badgeClass(entry) { + return `expiration-badge-${expirationInfo(entry).key}`; + }, + legendClass(key) { + return `legend-${key}`; + }, + expirationCount(key) { + return this.entries.filter((entry) => expirationInfo(entry).key === key).length; + }, + shortId(entry) { + return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id'; + }, + locationLabel(entry) { + return resolveLocationLabel(entry, this.locationMap); + }, + locationMatchesFilter(entryLocationUuid, selectedLocationUuid) { + if (!selectedLocationUuid) { + return true; + } + + const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid]; + return allowed.includes(entryLocationUuid); + }, + quantityLabel, + stockTypeDetail(entry) { + if (entry.stock_type === 'binary') { + return 'Binary stock'; + } + if (entry.stock_type === 'descriptive') { + return `Level: ${LEVEL_LABELS[entry.level] || 'Not set'}`; + } + return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock'; + }, + formatDate, + async updateBinary(entry, level) { + await this.deleteEntry(entry); + }, + async saveLevel(entry) { + const level = this.editForms[entry.id]?.level || 'plenty'; + await this.saveEntryUpdate(entry, { + level, + }, { level }); + }, + async saveQuantity(entry) { + const quantity = Number(this.editForms[entry.id]?.quantity); + if (Number.isNaN(quantity) || quantity < 0) { + this.editErrors[entry.id] = 'Enter a valid quantity first.'; + return; + } + + await this.saveEntryUpdate(entry, { + quantity, + }, { quantity }); + }, + async markMeasuredGone(entry) { + await this.deleteEntry(entry); + }, + async saveEntryUpdate(entry, payload, localPatch) { + this.editErrors[entry.id] = ''; + + try { + const updated = await updateStockItem(store, entry.uuid_b64, payload); + this.replaceEntry(entry.id, { ...entry, ...localPatch, ...updated }); + store.addAlert({ + type: 'success', + message: `${entry.name} updated successfully.`, + }); + } catch (error) { + this.editErrors[entry.id] = error.message || 'Update failed.'; + } + }, + async deleteEntry(entry) { + this.editErrors[entry.id] = ''; + + try { + await deleteStockItem(store, entry.uuid_b64); + this.entries = this.entries.filter((candidate) => candidate.id !== entry.id); + delete this.editForms[entry.id]; + delete this.editErrors[entry.id]; + store.addAlert({ + type: 'success', + message: `${entry.name} was marked gone and removed from the list.`, + }); + } catch (error) { + this.editErrors[entry.id] = error.message || 'Delete failed.'; + } + }, + replaceEntry(entryId, nextEntry) { + this.entries = sortEntries( + this.entries.map((entry) => (entry.id === entryId ? nextEntry : entry)), + ); + this.editForms[entryId] = { + level: nextEntry.level || 'plenty', + quantity: nextEntry.quantity ?? '', + }; + }, + }; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..97c2fe1 --- /dev/null +++ b/src/main.js @@ -0,0 +1,11 @@ +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap/dist/js/bootstrap.bundle.min.js'; +import './styles/app.css'; + +import Alpine from 'alpinejs'; + +import { bootstrapApp } from './app/bootstrap.js'; + +window.Alpine = Alpine; + +bootstrapApp(Alpine); diff --git a/src/styles/app.css b/src/styles/app.css new file mode 100644 index 0000000..1d26263 --- /dev/null +++ b/src/styles/app.css @@ -0,0 +1,405 @@ +:root { + --lonc-ink: #1f2740; + --lonc-muted: #667085; + --lonc-primary: #1f4b99; + --lonc-primary-dark: #163b7c; + --lonc-accent: #ebf3ff; + --lonc-surface: #ffffff; + --lonc-background: #f4f7fb; + --lonc-border: rgba(31, 39, 64, 0.08); + --bs-primary: #1f4b99; + --bs-primary-rgb: 31, 75, 153; + --bs-body-color: #1f2740; + --bs-body-bg: #f4f7fb; + --bs-border-color: rgba(31, 39, 64, 0.08); + --bs-font-sans-serif: "Avenir Next", "Segoe UI", sans-serif; +} + +body { + color: var(--lonc-ink); + background: + radial-gradient(circle at top left, rgba(90, 169, 255, 0.18), transparent 22%), + linear-gradient(180deg, #f8fbff 0%, var(--lonc-background) 42%, #eef2f8 100%); +} + +.app-shell { + position: relative; +} + +.brand-mark { + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 0.75rem; + background: linear-gradient(135deg, var(--lonc-primary), #5da9ff); + color: #fff; + font-weight: 700; +} + +.eyebrow { + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 0.75rem; + font-weight: 700; + color: var(--lonc-primary); +} + +.hero-card { + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(235, 243, 255, 0.92)), + linear-gradient(135deg, rgba(93, 169, 255, 0.18), rgba(31, 75, 153, 0.08)); + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 2rem; + box-shadow: 0 24px 48px rgba(24, 42, 79, 0.08); +} + +.glass-panel { + border-radius: 1.5rem; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); +} + +.quick-card, +.kitchen-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + min-height: 100%; + padding: 1.25rem; + border-radius: 1.5rem; + border: 1px solid var(--lonc-border); + background: rgba(255, 255, 255, 0.88); + color: inherit; + text-decoration: none; + transition: + transform 160ms ease, + box-shadow 160ms ease, + border-color 160ms ease; +} + +.quick-card:hover, +.kitchen-card:hover, +.clickable-row:hover { + transform: translateY(-2px); + box-shadow: 0 20px 40px rgba(24, 42, 79, 0.08); + border-color: rgba(31, 75, 153, 0.2); +} + +.quick-card-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--lonc-primary); +} + +.preview-frame, +.empty-preview { + min-height: 24rem; + border-radius: 1.5rem; + border: 1px dashed rgba(31, 75, 153, 0.22); + background: + linear-gradient(135deg, rgba(235, 243, 255, 0.88), rgba(255, 255, 255, 0.88)), + repeating-linear-gradient( + 45deg, + transparent, + transparent 12px, + rgba(31, 75, 153, 0.04) 12px, + rgba(31, 75, 153, 0.04) 24px + ); +} + +.preview-frame { + display: grid; + place-items: center; + padding: 1rem; +} + +.empty-preview { + display: grid; + place-items: center; + text-align: center; + color: var(--lonc-muted); + padding: 2rem; +} + +.status-chip { + text-transform: capitalize; +} + +.detail-grid dt, +.detail-grid dd { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + margin-bottom: 0; + border-bottom: 1px solid var(--lonc-border); +} + +.toast-stack { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 1090; + width: min(24rem, calc(100vw - 2rem)); +} + +.clear-field-button { + position: absolute; + top: 50%; + right: 0.75rem; + transform: translateY(-50%); + z-index: 5; + text-decoration: none; + font-size: 1.4rem; + line-height: 1; + padding: 0; + background: transparent; +} + +.search-field-with-clear .search-clear-button { + top: calc(50% + 1rem); +} + +.text-field-with-clear .inline-clear-button { + top: calc(50% + 1rem); +} + +.text-field-with-clear .textarea-clear-button { + top: 2.5rem; + transform: none; +} + +.location-field-with-clear .location-clear-button { + top: 50%; +} + +.location-picker { + z-index: 4; +} + +.location-level-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: rgba(31, 75, 153, 0.1); + color: var(--lonc-primary); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.grouped-field-with-clear { + position: relative; +} + +.grouped-field-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.legend-card { + height: 100%; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--lonc-border); +} + +.legend-expired, +.expiration-expired { + background: rgba(220, 53, 69, 0.1); +} + +.legend-use-first, +.expiration-use-first { + background: rgba(253, 126, 20, 0.12); +} + +.legend-upcoming, +.expiration-upcoming { + background: rgba(255, 193, 7, 0.14); +} + +.legend-within-date, +.expiration-within-date { + background: rgba(25, 135, 84, 0.08); +} + +.legend-none, +.expiration-none { + background: rgba(108, 117, 125, 0.08); +} + +.expiration-badge-expired { + background: #dc3545; + color: #fff; +} + +.expiration-badge-use-first { + background: #fd7e14; + color: #fff; +} + +.expiration-badge-upcoming { + background: #ffc107; + color: #2f2a12; +} + +.expiration-badge-within-date { + background: #198754; + color: #fff; +} + +.expiration-badge-none { + background: #6c757d; + color: #fff; +} + +.stock-review-table tbody tr td { + vertical-align: top; + transition: background-color 160ms ease; +} + +.stock-review-table tbody tr.expiration-expired td { + background: rgba(220, 53, 69, 0.08); +} + +.stock-review-table tbody tr.expiration-use-first td { + background: rgba(253, 126, 20, 0.1); +} + +.stock-review-table tbody tr.expiration-upcoming td { + background: rgba(255, 193, 7, 0.12); +} + +.stock-review-table tbody tr.expiration-within-date td { + background: rgba(25, 135, 84, 0.07); +} + +.stock-review-table tbody tr.expiration-none td { + background: rgba(108, 117, 125, 0.06); +} + +.stock-review-table tbody tr td:first-child { + border-left: 4px solid transparent; +} + +.stock-review-table tbody tr.expiration-expired td:first-child { + border-left-color: #dc3545; +} + +.stock-review-table tbody tr.expiration-use-first td:first-child { + border-left-color: #fd7e14; +} + +.stock-review-table tbody tr.expiration-upcoming td:first-child { + border-left-color: #ffc107; +} + +.stock-review-table tbody tr.expiration-within-date td:first-child { + border-left-color: #198754; +} + +.stock-review-table tbody tr.expiration-none td:first-child { + border-left-color: #6c757d; +} + +.stock-review-card { + border-left: 6px solid transparent; +} + +.stock-review-card.expiration-expired { + border-left-color: #dc3545; +} + +.stock-review-card.expiration-use-first { + border-left-color: #fd7e14; +} + +.stock-review-card.expiration-upcoming { + border-left-color: #ffc107; +} + +.stock-review-card.expiration-within-date { + border-left-color: #198754; +} + +.stock-review-card.expiration-none { + border-left-color: #6c757d; +} + +.quick-edit-stack { + min-width: 13rem; +} + +.quick-select { + min-width: 8rem; +} + +.quick-number { + max-width: 6rem; +} + +.stock-guide-summary { + list-style: none; + cursor: pointer; +} + +.stock-guide-summary::-webkit-details-marker { + display: none; +} + +.stock-filter-details summary { + list-style: none; +} + +.stock-filter-details summary::-webkit-details-marker { + display: none; +} + +.stock-filter-toolbar { + position: relative; + min-height: 3rem; +} + +.stock-filter-panel { + width: 100%; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--lonc-border); + background: rgba(255, 255, 255, 0.75); +} + +.stock-filter-clear { + position: absolute; + top: 0; + right: 0; + min-width: 5.5rem; +} + +.clickable-row { + cursor: pointer; +} + +@media (max-width: 991.98px) { + .navbar { + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.92) !important; + } + + .hero-card, + .quick-card, + .kitchen-card, + .preview-frame, + .empty-preview { + border-radius: 1.25rem; + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..0fd0f89 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 4173, + }, +});