diff --git a/check-webhook-data.mjs b/.codex similarity index 100% rename from check-webhook-data.mjs rename to .codex diff --git a/.env.example b/.env.example deleted file mode 100644 index 5b82e36e..00000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -# OneSignal configuration (SvelteKit style) -# Public values (exposed to client build): -PUBLIC_ONESIGNAL_APP_ID=00000000-0000-0000-0000-000000000000 -PUBLIC_ONESIGNAL_SAFARI_WEB_ID=web.onesignal.auto.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# Private (server only): -PRIVATE_ONESIGNAL_REST_API_KEY=OS_REST_API_KEY_HERE - -# Existing variables (append your current ones below if you have a separate example file) -PUBLIC_SITE_URL=https://cropwatch.io diff --git a/.github/workflows/ninja_i18n.yml b/.github/workflows/ninja_i18n.yml deleted file mode 100644 index c0ed0590..00000000 --- a/.github/workflows/ninja_i18n.yml +++ /dev/null @@ -1,23 +0,0 @@ - -name: Ninja i18n action - -on: pull_request_target - -# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings -permissions: - pull-requests: write # Necessary to comment on PRs - issues: read # Necessary to read issue comments - contents: read # Necessary to access the repo content - -jobs: - ninja-i18n: - name: Ninja i18n - GitHub Lint Action - runs-on: ubuntu-latest - - steps: - - name: Run Ninja i18n - # @main ensures that the latest version of the action is used - uses: opral/ninja-i18n-action@main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7665b01f..51840a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -test-results node_modules # Output @@ -7,14 +6,13 @@ node_modules .netlify .wrangler /.svelte-kit +/.svelte-kit.bak.* /build # OS .DS_Store Thumbs.db -# AI -DOCS/* # Env .env .env.* @@ -24,6 +22,8 @@ DOCS/* # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* - +# Playwright +test-results # Paraglide src/lib/paraglide +project.inlang/cache/ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index a5a29d9f..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -pnpm lint-staged diff --git a/.npmrc b/.npmrc index 4a1e4811..5c14126a 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,5 @@ -# @cropwatchdevelopment:registry=https://npm.pkg.github.com +engine-strict=true +node-linker=hoisted +@cropwatchdevelopment:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} +always-auth=true diff --git a/.prettierignore b/.prettierignore index 6562bcbb..7d74fe24 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ pnpm-lock.yaml yarn.lock bun.lock bun.lockb + +# Miscellaneous +/static/ diff --git a/.prettierrc b/.prettierrc index 7ebb855b..819fa576 100644 --- a/.prettierrc +++ b/.prettierrc @@ -11,5 +11,6 @@ "parser": "svelte" } } - ] + ], + "tailwindStylesheet": "./src/routes/layout.css" } diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml deleted file mode 100644 index 57d7668c..00000000 --- a/.qlty/qlty.toml +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore all auto-generated type files -exclude_patterns = [ - "**/database.types.ts" -] diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 05cc1965..629f0948 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,3 @@ { - "recommendations": [ - "svelte.svelte-vscode", - "svelte.svelte-vscode-sveltekit", - "svelte.svelte-vscode-tailwindcss", - "svelte.svelte-vscode-testing-library", - ] -} \ No newline at end of file + "recommendations": ["inlang.vs-code-extension"] +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json index bf436f29..89132a15 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,23 +1,10 @@ { - "inputs": [ - { - "type": "promptString", - "id": "supabase-access-token", - "description": "Supabase personal access token", - "password": true - } - ], "servers": { + "svelte": { + "url": "https://mcp.svelte.dev/mcp" + }, "supabase": { - "command": "npx", - "args": [ - "-y", - "@supabase/mcp-server-supabase@latest" - ], - "env": { - "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" - }, - "type": "stdio" + "url": "https://mcp.supabase.com/mcp?project_ref=dpaoqrcfswnzknixwkll&read_only=true" } } -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 01d37aea..8c5dbaad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,7 @@ { - "i18n-ally.localesPaths": [ - "messages", - "src/lib/i18n", - "src/lib/i18n/locales", - "src/lib/paraglide/messages" - ] -} \ No newline at end of file + "files.associations": { + "*.css": "css" + }, + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 250 +} diff --git a/AGENTS.md b/AGENTS.md index 68dcb9e9..a6e66ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,4 +20,4 @@ You MUST use this tool whenever writing Svelte code before sending it to the use ### 4. playground-link Generates a Svelte Playground link with the provided code. -After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. \ No newline at end of file +After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. diff --git a/JWT_PDF_API_GUIDE.md b/JWT_PDF_API_GUIDE.md deleted file mode 100644 index ae84635b..00000000 --- a/JWT_PDF_API_GUIDE.md +++ /dev/null @@ -1,158 +0,0 @@ -# JWT PDF Generation API Guide - -## 📋 **New Endpoint Created** - -### **Endpoint:** `/api/devices/{devEui}/pdf` -- **Method:** `GET` -- **Authentication:** JWT Bearer token required -- **Purpose:** Generate PDF reports for device data (server-to-server) -- **Use Cases:** Node-RED automation, Postman testing, external integrations - ---- - -## 🔑 **Authentication Steps** - -### 1. **Get JWT Token** -```bash -# Login to get JWT token -curl -X POST "http://localhost:5173/api/auth/login" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "your-email@example.com", - "password": "your-password" - }' -``` - -**Response:** -```json -{ - "user": { ... }, - "session": { - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "expires_at": 1735689600 - } -} -``` - -### 2. **Extract the `access_token`** from the response - ---- - -## 📄 **Generate PDF Report** - -### **Postman/cURL Example:** -```bash -curl -X GET "http://localhost:5173/api/devices/2CF7F1C0630000AC/pdf?start=2025-05-01&end=2025-06-06" \ - -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" \ - --output "device-report.pdf" -``` - -### **Parameters:** -- `start`: Start date (YYYY-MM-DD format) -- `end`: End date (YYYY-MM-DD format) -- `devEui`: Device EUI (in URL path) - -### **Headers Required:** -- `Authorization: Bearer {your-jwt-token}` - ---- - -## ✅ **Expected Response** - -### **Success (200):** -- **Content-Type:** `application/pdf` -- **Content-Disposition:** `attachment; filename="device-{devEui}-report-{start}-to-{end}.pdf"` -- **Body:** PDF binary data - -### **Error Responses:** - -#### **401 Unauthorized:** -```json -{ - "error": "Authorization header with Bearer token is required" -} -``` - -#### **403 Forbidden:** -```json -{ - "error": "You do not have permission to access this device" -} -``` - -#### **404 Not Found:** -```json -{ - "error": "No data found for device 2CF7F1C0630000AC in the specified date range", - "device": "2CF7F1C0630000AC", - "dateRange": { "start": "2025-05-01", "end": "2025-06-06" }, - "user": "user@example.com" -} -``` - -#### **400 Bad Request:** -```json -{ - "error": "Missing required parameters: Both start and end dates are required", - "example": "?start=2025-05-01&end=2025-06-06" -} -``` - ---- - -## 🤖 **Node-RED Integration** - -### **HTTP Request Node Configuration:** -- **Method:** `GET` -- **URL:** `http://your-domain.com/api/devices/{devEui}/pdf?start=2025-05-01&end=2025-06-06` -- **Headers:** - ```json - { - "Authorization": "Bearer {{jwt_token}}" - } - ``` -- **Return:** `a binary buffer` - -### **Flow Example:** -``` -[Inject] → [HTTP Request] → [File Out] → [Email/Processing] -``` - ---- - -## 📊 **PDF Content (Professional Layout)** - -The generated PDF now matches the browser version exactly: -- **Japanese Header:** "デバイスレポート" with device EUI -- **Professional Metadata:** Period, user, generation time in Japanese -- **Data Table Features:** - - Color-coded temperature values (red/orange/yellow/green) - - Proper Japanese column headers: "日時", "時刻", "値1", "値2" - - 30-minute interval sampling for optimal data density - - Temperature filtering (>-20°C) to remove invalid readings - - Multiple pages with consistent formatting -- **High-Quality Output:** Same PDFKit engine as browser version - ---- - -## 🛡️ **Security Features** - -- ✅ **JWT Authentication:** Validates token before processing -- ✅ **Permission Checking:** Verifies user has access to the device -- ✅ **RLS Compliance:** Respects Supabase Row Level Security -- ✅ **Error Handling:** Detailed error messages for debugging -- ✅ **Audit Trail:** Logs all PDF generation requests - ---- - -## 🧪 **Testing Checklist** - -- [ ] Test with valid JWT token -- [ ] Test with expired JWT token -- [ ] Test with invalid device EUI -- [ ] Test with date range that has no data -- [ ] Test with date range that has data -- [ ] Verify PDF downloads correctly -- [ ] Check PDF content is readable -- [ ] Test Node-RED integration -- [ ] Verify error responses are helpful \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0e259d42..00000000 --- a/LICENSE +++ /dev/null @@ -1,121 +0,0 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. diff --git a/README.md b/README.md index d193b66f..0002a800 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,43 @@ -

CropWatch Web UI

-

Our PWA Web Application for viewing and controlling your farm from anywhere and everywhere.

-
- - [![CodeQL - Master](https://github.com/CropWatchDevelopment/CropWatch/actions/workflows/github-code-scanning/codeql/badge.svg?branch=master)](https://github.com/CropWatchDevelopment/CropWatch/actions/workflows/github-code-scanning/codeql) - [![CodeQL - Develop](https://github.com/CropWatchDevelopment/CropWatch/actions/workflows/github-code-scanning/codeql/badge.svg?branch=develop)](https://github.com/CropWatchDevelopment/CropWatch/actions/workflows/github-code-scanning/codeql) - [![codecov](https://codecov.io/gh/CropWatchDevelopment/CropWatch/graph/badge.svg?token=H0LAIQ38KG)](https://codecov.io/gh/CropWatchDevelopment/CropWatch) - [![Maintainability](https://qlty.sh/gh/CropWatchDevelopment/projects/CropWatch/maintainability.svg)](https://qlty.sh/gh/CropWatchDevelopment/projects/CropWatch) +# sv -
-
-

Loved the project? Please visit our Website

-
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). -## About CropWatch Web UI -CropWatch Web UI is a progressive web application (PWA) built with SvelteKit, designed for farmers, agronomists, and farm managers to monitor and control their farm operations remotely. -It aggregates sensor data from soil and air devices, visualizes key metrics, and enables custom rule-based alerts to streamline decision-making. +## Creating a project -### Key Features -- **Dashboard**: Customizable data cards and interactive charts for real-time insights. -- **Mapping**: Geospatial views of sensor locations using Leaflet. -- **Alerts & Rules**: Configure thresholds to trigger push notifications and highlight anomalies. -- **PDF Reports**: Export detailed device and trend reports via jsPDF and html2canvas. -- **Localization**: Multi-language support with svelte-i18n for English, Spanish, and Japanese. +If you're seeing this, you've probably already done this step. Congrats! -### How It Works -1. Field sensors collect environmental and soil metrics and send them to a Supabase backend. -2. The UI uses @supabase/supabase-js for data retrieval and @supabase/ssr for server-side rendering. -3. Reactive Svelte stores (@stencil/store) manage state and update UI components dynamically. -4. Users define rules in the UI; when sensor data crosses thresholds, the system highlights issues in real time. -5. On-demand PDF generation allows exporting reports for offline sharing or record-keeping. +```sh +# create a new project +npx sv create my-app +``` -### Why Contribute? -We welcome contributions from developers, designers, and farmers alike to make CropWatch more robust and user-friendly: -- Enhance existing UI components or add new visualizations. -- Integrate new sensor types or third-party services. -- Improve performance, accessibility, and test coverage. -- Translate the application into additional languages. +To recreate this project with the same configuration: -Fork the repo, create a branch with your feature or fix, and submit a pull request. See the [Contribute](#contribute) section below for details. +```sh +# recreate this project +pnpm dlx sv create --template demo --types ts --add prettier eslint vitest="usages:unit,component" playwright tailwindcss="plugins:typography,forms" sveltekit-adapter="adapter:vercel" devtools-json mcp="ide:vscode+setup:remote" --install pnpm CropWatch +``` -This repo aims to host a UI that everyone can update and add to so that we can make farming as easy as possible through the use of technology. +## Developing -If you are interested, please create a PR to add your `README` profile here. +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: -If you like this Repo, please click the :star: +```sh +npm run dev -List of `CropWatch UI` categories mentioned below +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` -## Contents - - [Contribute](#contribute) +## Building -## Website +To create a production version of your app: -Link : https://app.CropWatch.io +```sh +npm run build +``` -Current Production UI +You can preview the production build with `npm run preview`. # Environments - [UAT](https://crop-watch-i2xiyd6mo-crop-watch-team.vercel.app/) diff --git a/SUPABAES_SETUP_SCRIPTS/CW_RULES/rls.sql b/SUPABAES_SETUP_SCRIPTS/CW_RULES/rls.sql deleted file mode 100644 index 55d0d730..00000000 --- a/SUPABAES_SETUP_SCRIPTS/CW_RULES/rls.sql +++ /dev/null @@ -1,44 +0,0 @@ -CREATE POLICY "Allow authenticated users to select cw_rules." -ON public.cw_rules -FOR SELECT -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT user_id FROM public.cw_device_owners WHERE dev_eui = cw_rules.dev_eui - ) -); - -CREATE POLICY "Allow authenticated users to insert cw_rules." -ON public.cw_rules -FOR INSERT -TO authenticated -WITH CHECK ( - (SELECT auth.uid()) IN ( - SELECT user_id FROM public.cw_device_owners WHERE dev_eui = cw_rules.dev_eui - ) -); - -CREATE POLICY "Allow authenticated users to update cw_rules." -ON public.cw_rules -FOR UPDATE -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT user_id FROM public.cw_device_owners WHERE dev_eui = cw_rules.dev_eui - ) -) -WITH CHECK ( - (SELECT auth.uid()) IN ( - SELECT user_id FROM public.cw_device_owners WHERE dev_eui = cw_rules.dev_eui - ) -); - -CREATE POLICY "Allow authenticated users to delete cw_rules." -ON public.cw_rules -FOR DELETE -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT user_id FROM public.cw_device_owners WHERE dev_eui = cw_rules.dev_eui - ) -); \ No newline at end of file diff --git a/SUPABAES_SETUP_SCRIPTS/CW_RULE_CRITERIA/rls.sql b/SUPABAES_SETUP_SCRIPTS/CW_RULE_CRITERIA/rls.sql deleted file mode 100644 index d07f12f1..00000000 --- a/SUPABAES_SETUP_SCRIPTS/CW_RULE_CRITERIA/rls.sql +++ /dev/null @@ -1,44 +0,0 @@ -CREATE POLICY "Allow authenticated users to select cw_rule_criteria." -ON public.cw_rule_criteria -FOR SELECT -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT profile_id FROM public.cw_rules WHERE "ruleGroupId" = public.cw_rule_criteria."ruleGroupId" - ) -); - -CREATE POLICY "Allow authenticated users to insert cw_rule_criteria." -ON public.cw_rule_criteria -FOR INSERT -TO authenticated -WITH CHECK ( - (SELECT auth.uid()) IN ( - SELECT profile_id FROM public.cw_rules WHERE "ruleGroupId" = public.cw_rule_criteria."ruleGroupId" - ) -); - -CREATE POLICY "Allow authenticated users to update cw_rule_criteria." -ON public.cw_rule_criteria -FOR UPDATE -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT profile_id FROM public.cw_rules WHERE "ruleGroupId" = public.cw_rule_criteria."ruleGroupId" - ) -) -WITH CHECK ( - (SELECT auth.uid()) IN ( - SELECT profile_id FROM public.cw_rules WHERE "ruleGroupId" = public.cw_rule_criteria."ruleGroupId" - ) -); - -CREATE POLICY "Allow authenticated users to delete cw_rule_criteria." -ON public.cw_rule_criteria -FOR DELETE -TO authenticated -USING ( - (SELECT auth.uid()) IN ( - SELECT profile_id FROM public.cw_rules WHERE "ruleGroupId" = public.cw_rule_criteria."ruleGroupId" - ) -); \ No newline at end of file diff --git a/SUPABAES_SETUP_SCRIPTS/STORED_PROCS/get_report_data_for_device.sql b/SUPABAES_SETUP_SCRIPTS/STORED_PROCS/get_report_data_for_device.sql deleted file mode 100644 index 008a8a9d..00000000 --- a/SUPABAES_SETUP_SCRIPTS/STORED_PROCS/get_report_data_for_device.sql +++ /dev/null @@ -1,158 +0,0 @@ -CREATE OR REPLACE FUNCTION get_report_data_for_device( - input_dev_eui TEXT, - input_start TIMESTAMPTZ, - input_end TIMESTAMPTZ, - input_timezone TEXT DEFAULT 'UTC', - input_interval_minutes INT DEFAULT 60 -) -RETURNS JSONB -LANGUAGE plpgsql -AS $$ -DECLARE - data_table TEXT; - data_query TEXT; - raw_data JSONB := '[]'; - filtered_data JSONB := '[]'; - report_data JSONB := '[]'; -BEGIN - -- Step 1: find the device's data table - SELECT cdt.data_table_v2 - INTO data_table - FROM cw_devices cd - JOIN cw_device_type cdt - ON cdt.id = cd."type" - WHERE cd.dev_eui = input_dev_eui; - - IF data_table IS NULL THEN - RAISE EXCEPTION 'No data table found for device %', input_dev_eui; - END IF; - - -- Step 2: sample every X minutes OR include alert‐triggering rows - data_query := format($sql$ - SELECT jsonb_agg( - to_jsonb(t) - || jsonb_build_object( - 'created_at', - to_char( - t.created_at AT TIME ZONE 'UTC' - AT TIME ZONE $1, - 'YYYY-MM-DD"T"HH24:MI:SSOF' - ) - ) - - 'created_at' - ) - FROM %I t - WHERE - t.dev_eui = $4 - AND t.is_simulated = false - AND t.created_at BETWEEN $2 AND $3 - AND ( - -- (a) falls exactly on an interval boundary from input_start - ((EXTRACT(EPOCH FROM (t.created_at - $2)) / 60)::INT %% $5) = 0 - - OR - - -- (b) matches one of the report's alert thresholds - EXISTS ( - SELECT 1 - FROM report_alert_points rap - WHERE rap.report_id = ( - SELECT r.report_id - FROM reports r - WHERE r.dev_eui = $4 - LIMIT 1 - ) - AND ( - (rap.operator = '>' AND t.temperature_c > rap.value) - OR (rap.operator = '<' AND t.temperature_c < rap.value) - OR (rap.operator = '>=' AND t.temperature_c >= rap.value) - OR (rap.operator = '<=' AND t.temperature_c <= rap.value) - OR (rap.operator = '=' AND t.temperature_c = rap.value) - ) - ) - ) - $sql$, data_table); - - EXECUTE data_query - INTO raw_data - USING input_timezone, input_start, input_end, input_dev_eui, input_interval_minutes; - - raw_data := COALESCE(raw_data, '[]'::jsonb); - - -- Step 3: strip out nulls and unwanted keys (including is_simulated) - filtered_data := ( - SELECT COALESCE( - jsonb_agg( - ( jsonb_strip_nulls(elem) - - ARRAY[ - 'battery_level', - 'smoke_detected', - 'vape_detected', - 'is_simulated' - ] - ) - ), - '[]'::jsonb - ) - FROM jsonb_array_elements(raw_data) AS t(elem) - ); - - -- Step 4: assemble report_info as before - SELECT jsonb_agg( - jsonb_strip_nulls(to_jsonb(r) - || jsonb_build_object( - 'alert_points', - ( SELECT jsonb_agg(jsonb_strip_nulls(to_jsonb(rap))) - FROM report_alert_points rap - WHERE rap.report_id = r.report_id - ), - 'recipients', - ( SELECT jsonb_agg( - jsonb_strip_nulls(to_jsonb(rr) - || jsonb_build_object( - 'recipient_name', p.full_name, - 'email', p.email, - 'communication_method', cm.name - ) - ) - ) - FROM report_recipients rr - JOIN profiles p - ON p.id = rr.profile_id - JOIN communication_methods cm - ON cm.communication_method_id = rr.communication_method - WHERE rr.report_id = r.report_id - ), - 'schedules', - ( SELECT jsonb_agg(jsonb_strip_nulls(to_jsonb(s))) - FROM report_user_schedule s - WHERE s.report_id = r.report_id - ) - ) - ) - ) - INTO report_data - FROM reports r - WHERE r.dev_eui = input_dev_eui; - - RETURN jsonb_build_object( - 'device_data', COALESCE(filtered_data, '[]'::jsonb), - 'report_info', COALESCE(report_data, '[]'::jsonb) - ); -END; -$$; - - - --- Example usage: - -SELECT get_report_data_for_device( - '373632336F32840A', - '2025-06-01T00:00:00+09', - '2025-06-09T00:00:00+09', - 'Asia/Tokyo', - 30 -) AS report_json; - - - diff --git a/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql b/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql deleted file mode 100644 index f810bae0..00000000 --- a/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql +++ /dev/null @@ -1,34 +0,0 @@ -CREATE OR REPLACE FUNCTION public.fn_log_rule_trigger() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - -- Only handle rising edge: false -> true - IF COALESCE(OLD.is_triggered, false) = false - AND COALESCE(NEW.is_triggered, false) = true - THEN - -- -- Stamp and bump (commented out for now) - -- NEW.last_triggered := (now() AT TIME ZONE 'utc'); - -- NEW.trigger_count := COALESCE(OLD.trigger_count, 0) + 1; - - -- Insert log row if we have both NOT NULL values required by FK - IF NEW.dev_eui IS NOT NULL AND NEW."ruleGroupId" IS NOT NULL THEN - INSERT INTO public.cw_rule_triggered (dev_eui, rule_group_id) - VALUES (NEW.dev_eui, NEW."ruleGroupId"); - END IF; - END IF; - - RETURN NEW; -END; -$$; - - -DROP TRIGGER IF EXISTS trg_cw_rules_log_trigger ON public.cw_rules; - -CREATE TRIGGER trg_cw_rules_log_trigger -BEFORE UPDATE OF is_triggered ON public.cw_rules -FOR EACH ROW -WHEN (COALESCE(OLD.is_triggered, false) IS DISTINCT FROM COALESCE(NEW.is_triggered, false)) -EXECUTE FUNCTION public.fn_log_rule_trigger(); \ No newline at end of file diff --git a/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql b/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql deleted file mode 100644 index e63b5f37..00000000 --- a/SUPABAES_SETUP_SCRIPTS/braudcasts/broadcastsetup.sql +++ /dev/null @@ -1,79 +0,0 @@ -create or replace function public.cw_air_data_changes() -returns trigger -security definer -language plpgsql -set search_path = '' -as $$ -begin - -- Broadcast the change event to the “cw_air_data” topic. - -- TG_OP is the operation (INSERT/UPDATE/DELETE), TG_TABLE_NAME is the table, - -- TG_TABLE_SCHEMA is the schema, NEW is the new row, OLD is the old row. - perform realtime.broadcast_changes( - 'cw_air_data', -- topic name - TG_OP, -- event name (INSERT/UPDATE/DELETE) - TG_OP, -- operation type - TG_TABLE_NAME, -- table name - TG_TABLE_SCHEMA, -- schema name - NEW, -- new record - OLD -- old record - ); - return null; -end; -$$; - -create trigger cw_air_data_broadcast_trigger -after insert or update or delete on public.cw_air_data -for each row -execute function cw_air_data_changes(); - - - - ---------------------- CW_RELAY_DATA TABLE --------------------- --- Create or replace the function for broadcasting relay data changes -create or replace function public.cw_relay_data_changes() -returns trigger -security definer -language plpgsql -set search_path = '' -as $$ -begin - -- Broadcast the change event to the “cw_relay_data” topic. - perform realtime.broadcast_changes( - 'cw_relay_data', -- topic name - TG_OP, -- event name (INSERT/UPDATE/DELETE) - TG_OP, -- operation type - TG_TABLE_NAME, -- table name - TG_TABLE_SCHEMA, -- schema name - NEW, -- new record - OLD -- old record - ); - return null; -end; -$$; - --- Create the trigger that calls the function -create trigger cw_relay_data_broadcast_trigger -after insert or update or delete on public.cw_relay_data -for each row -execute function public.cw_relay_data_changes(); - --- Allow authenticated users to receive broadcast messages on cw_relay_data -create policy "authenticated can receive cw_relay_data" - on realtime.messages - for select - to authenticated - using ( - realtime.topic() = 'cw_relay_data' AND - realtime.messages.extension = 'broadcast' - ); - --- Allow authenticated users to send broadcast messages on cw_relay_data -create policy "authenticated can send cw_relay_data" - on realtime.messages - for insert - to authenticated - with check ( - realtime.topic() = 'cw_relay_data' AND - realtime.messages.extension = 'broadcast' - ); \ No newline at end of file diff --git a/SUPABASE_STORED_PROCS/get_filtered_device_report_data.sql b/SUPABASE_STORED_PROCS/get_filtered_device_report_data.sql deleted file mode 100644 index 84e252d3..00000000 --- a/SUPABASE_STORED_PROCS/get_filtered_device_report_data.sql +++ /dev/null @@ -1,89 +0,0 @@ -CREATE OR REPLACE FUNCTION get_filtered_device_report_data( - dev_id TEXT, - start_time TIMESTAMPTZ, - end_time TIMESTAMPTZ, - interval_minutes INTEGER, - target_column TEXT, - compare_operator TEXT, - compare_value_min DOUBLE PRECISION, - compare_value_max DOUBLE PRECISION DEFAULT NULL -) -RETURNS SETOF JSONB -LANGUAGE plpgsql -AS $$ -DECLARE - target_table TEXT; - sql TEXT; - column_list TEXT; -BEGIN - -- 1. Get table name from device type - SELECT cdt.data_table_v2 - INTO target_table - FROM cw_devices dev - JOIN cw_device_type cdt ON dev."type" = cdt.id - WHERE dev.dev_eui = dev_id; - - -- 2. Build column list dynamically - SELECT string_agg(quote_ident(column_name), ', ') - INTO column_list - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = target_table - AND column_name NOT IN ('bucket'); -- Exclude any system fields - - IF column_list IS NULL THEN - RAISE EXCEPTION 'Could not determine column list for table %', target_table; - END IF; - - -- 3. Construct the SQL dynamically - sql := format($f$ - WITH filtered AS ( - SELECT %s - FROM %I - WHERE dev_eui = %L - AND created_at BETWEEN %L AND %L - ), - sampled AS ( - SELECT *, - FLOOR(EXTRACT(EPOCH FROM created_at - %L::timestamp) / (%s * 60)) AS bucket - FROM filtered - ), - dedup AS ( - SELECT DISTINCT ON (bucket) %s - FROM sampled - ORDER BY bucket, created_at - ), - exceptions AS ( - SELECT %s - FROM filtered - WHERE %I %s %s - ) - SELECT to_jsonb(x) - FROM ( - SELECT * FROM dedup - UNION - SELECT * FROM exceptions - ) x - ORDER BY created_at - $f$, - column_list, - target_table, - dev_id, start_time, end_time, - start_time, - interval_minutes, - column_list, - column_list, - target_column, - compare_operator, - CASE - WHEN compare_operator ILIKE 'between' THEN format('%L AND %L', compare_value_min, compare_value_max) - ELSE format('%L', compare_value_min) - END - ); - - -- Optional debug - RAISE NOTICE 'Executing SQL: %', sql; - - -- 4. Execute - RETURN QUERY EXECUTE sql; -END $$; \ No newline at end of file diff --git a/SUPABASE_STORED_PROCS/get_filtered_device_report_data_multi_v2.sql b/SUPABASE_STORED_PROCS/get_filtered_device_report_data_multi_v2.sql deleted file mode 100644 index 78dffbdc..00000000 --- a/SUPABASE_STORED_PROCS/get_filtered_device_report_data_multi_v2.sql +++ /dev/null @@ -1,136 +0,0 @@ --- 1) Create the function -CREATE OR REPLACE FUNCTION get_filtered_device_report_data_multi_v2( - p_dev_id TEXT, - p_start_time TIMESTAMPTZ, - p_end_time TIMESTAMPTZ, - p_interval_minutes INTEGER, - p_columns TEXT[], -- e.g. ['temperature_c','humidity'] - p_ops TEXT[], -- e.g. ['>','BETWEEN'] - p_mins DOUBLE PRECISION[], -- e.g. [-22, 55] - p_maxs DOUBLE PRECISION[] -- e.g. [NULL, 65] - p_timezone TEXT DEFAULT 'UTC' -) -RETURNS SETOF JSONB -LANGUAGE plpgsql -AS $function$ -DECLARE - v_target_table TEXT; - candidate_cols TEXT[]; - final_cols TEXT[] := ARRAY[]::TEXT[]; - col TEXT; - has_nonnull BOOLEAN; - column_list TEXT; - i INT; - cond_clauses TEXT[] := ARRAY[]::TEXT[]; - exceptions_where TEXT; - sql TEXT; - excluded_cols TEXT[] := ARRAY[ - 'dev_eui','smoke_detected','vape_detected','battery_level','is_simulated' - ]; -BEGIN - -- Ensure timestamptz fields respect the caller's timezone when converted to text/JSON - PERFORM set_config('TimeZone', COALESCE(NULLIF(p_timezone, ''), 'UTC'), true); - - -- 1) lookup table name - SELECT cdt.data_table_v2 - INTO v_target_table - FROM cw_devices dev - JOIN cw_device_type cdt ON dev."type" = cdt.id - WHERE dev.dev_eui = p_dev_id; - IF v_target_table IS NULL THEN - RAISE EXCEPTION 'No data table for dev_eui=%', p_dev_id; - END IF; - - -- 2) gather actual columns - SELECT array_agg(column_name) - INTO candidate_cols - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = v_target_table - AND column_name <> ALL(excluded_cols); - IF candidate_cols IS NULL THEN - RAISE EXCEPTION 'No columns in %', v_target_table; - END IF; - - -- 3) filter to only columns with data in the window - FOREACH col IN ARRAY candidate_cols LOOP - EXECUTE format( - 'SELECT EXISTS(SELECT 1 FROM %I WHERE dev_eui = %L AND created_at BETWEEN %L AND %L AND %I IS NOT NULL)', - v_target_table, p_dev_id, p_start_time, p_end_time, col - ) INTO has_nonnull; - IF has_nonnull THEN - final_cols := final_cols || quote_ident(col); - END IF; - END LOOP; - IF array_length(final_cols,1) IS NULL THEN - RAISE EXCEPTION 'No populated columns in %', v_target_table; - END IF; - column_list := array_to_string(final_cols, ', '); - - -- 4) build exception clauses from the four parallel arrays - IF NOT ( - array_length(p_columns,1) = array_length(p_ops,1) - AND array_length(p_columns,1) = array_length(p_mins,1) - AND array_length(p_columns,1) = array_length(p_maxs,1) - ) THEN - RAISE EXCEPTION 'Array lengths for columns/ops/mins/maxs must match'; - END IF; - - FOR i IN 1 .. array_length(p_columns,1) LOOP - IF p_ops[i] ILIKE 'between' AND p_maxs[i] IS NOT NULL THEN - cond_clauses := cond_clauses || - format('%I BETWEEN %L AND %L', p_columns[i], p_mins[i], p_maxs[i]); - ELSE - cond_clauses := cond_clauses || - format('%I %s %L', p_columns[i], p_ops[i], p_mins[i]); - END IF; - END LOOP; - exceptions_where := array_to_string(cond_clauses, ' OR '); - - -- 5) assemble & run the dynamic SQL - sql := format($query$ - WITH filtered AS ( - SELECT %s - FROM %I - WHERE dev_eui = %L - AND created_at BETWEEN %L AND %L - ), - sampled AS ( - SELECT *, - FLOOR( - EXTRACT(EPOCH FROM created_at - %L::timestamptz) - / (%s * 60) - ) AS bucket - FROM filtered - ), - dedup AS ( - SELECT DISTINCT ON (bucket) %s - FROM sampled - ORDER BY bucket, created_at DESC - ), - exceptions AS ( - SELECT %s - FROM filtered - WHERE %s - ) - SELECT to_jsonb(x) - FROM ( - SELECT * FROM dedup - UNION - SELECT * FROM exceptions - ) x - ORDER BY created_at - $query$, - column_list, -- for filtered - v_target_table, - p_dev_id, p_start_time, p_end_time, - p_start_time, -- for bucket calc - p_interval_minutes, - column_list, -- for dedup - column_list, -- for exceptions - exceptions_where -- combined WHERE clause - ); - - RETURN QUERY EXECUTE sql; -END; -$function$; diff --git a/THEME.md b/THEME.md deleted file mode 100644 index b8c11205..00000000 --- a/THEME.md +++ /dev/null @@ -1,60 +0,0 @@ -# Theme System - -Centralized theme management supports three modes: `light`, `dark`, and `system`. - -## How It Works -- User preference stored in `localStorage` under `theme.mode`. -- `themeStore` (in `src/lib/stores/theme.ts`) exposes `{ mode, effective, system }`. -- `effective` is the applied theme (`light` or `dark`). If `mode === 'system'`, it mirrors OS preference; otherwise user selection overrides OS. -- The `` element gets the `dark` class when effective theme is dark, plus a `data-theme="light|dark"` attribute for additional hooks. - -## API -```ts -import { themeStore, setThemeMode, toggleExplicitLightDark } from '$lib/stores/theme'; -setThemeMode('dark'); // force dark -setThemeMode('light'); // force light -setThemeMode('system'); // follow OS -``` - -## UI Component: ThemeModeSelector -Use the built-in selector component for a user-facing control: -```svelte - - - -``` -Desktop renders a segmented control; mobile falls back to a ` { - placeholder = placeholder.set({ month: selectedMonth }); - }} - bind:value={selectedMonth} -> - - - - - -``` - -## Updating DateValue Objects - -Since `DateValue` objects are immutable, you must create new instances when updating them: - -```ts -// INCORRECT - will not work -let placeholder = new CalendarDate(2024, 7, 10); -placeholder.month = 8; // Error! DateValue objects are immutable -// CORRECT - using methods that return new instances -let placeholder = new CalendarDate(2024, 7, 10); -// Method 1: Using set() -placeholder = placeholder.set({ month: 8 }); -// Method 2: Using add() -placeholder = placeholder.add({ months: 1 }); -// Method 3: Using subtract() -placeholder = placeholder.subtract({ days: 5 }); -// Method 4: Using cycle() - cycles through valid values -placeholder = placeholder.cycle("month", "forward", [1, 3, 5, 7, 9, 11]); -``` - -## Formatting and Parsing - -### Formatting Dates for Display - -For consistent, locale-aware date formatting, use the `DateFormatter` class: - -```ts -import { DateFormatter } from "@internationalized/date"; -// Create a formatter for the current locale -const formatter = new DateFormatter("en-US", { - dateStyle: "full", - timeStyle: "short", -}); -// Format a DateValue -const formattedDate = formatter.format(myDateValue.toDate("America/New_York")); -// Example output: "Wednesday, July 10, 2024 at 12:30 PM" -``` - -The `DateFormatter` wraps the native [Intl.DateTimeFormat API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) while fixing browser inconsistencies and polyfilling newer features. - -### Parsing Date Strings - -When working with date strings from APIs or databases, use the appropriate parsing function for your needs: - -```ts -import { - parseDate, // For CalendarDate - parseDateTime, // For CalendarDateTime - parseZonedDateTime, // For ZonedDateTime with timezone name - parseAbsolute, // For ZonedDateTime from UTC string + timezone - parseAbsoluteToLocal, // For ZonedDateTime in local timezone -} from "@internationalized/date"; -// Examples -const date = parseDate("2024-07-10"); // CalendarDate -const dateTime = parseDateTime("2024-07-10T12:30:00"); // CalendarDateTime -const zonedDate = parseZonedDateTime("2024-07-12T00:45[America/New_York]"); // ZonedDateTime -const absoluteDate = parseAbsolute("2024-07-12T07:45:00Z", "America/New_York"); // ZonedDateTime -const localDate = parseAbsoluteToLocal("2024-07-12T07:45:00Z"); // ZonedDateTime in user's timezone -``` - -## Common Gotchas and Tips - -- **Month Indexing**: Unlike JavaScript's Date object (which is 0-indexed), `@internationalized/date` uses 1-indexed months (January = 1). -- **Immutability**: Always reassign when modifying date objects: `date = date.add({ days: 1 })` . -- **Timezone Handling**: Use `ZonedDateTime` for schedule-critical events like meetings or appointments. -- **Type Consistency**: Match `placeholder` types to your needs - if you need time selection, use `CalendarDateTime` not `CalendarDate` . -- **Performance**: Create `DateFormatter` instances once and reuse them rather than creating new instances on each render. - -## Related Resources - -- [Date Field](/docs/components/date-field) -- [Date Range Field](/docs/components/date-range-field) -- [Date Picker](/docs/components/date-picker) -- [Date Range Picker](/docs/components/date-range-picker) -- [Calendar](/docs/components/calendar) -- [Range Calendar](/docs/components/range-calendar) -- [@internationalized/date documentation](https://react-spectrum.adobe.com/internationalized/date/index.html) - ----------------------------------------------------- - -# Getting Started Documentation - -Learn how to get started using Bits in your app. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -Welcome to Bits UI, a collection of headless component primitives for Svelte 5 that prioritizes developer experience, accessibility, and flexibility. This guide will help you quickly integrate Bits UI into your Svelte application. - -## Installation - -Install bits using your preferred package manager. - -```bash -npm install bits-ui -``` - -## Basic Usage - -After installation, you can import and use Bits UI components in your Svelte files. Here's a simple example using the [Accordion](/docs/components/accordion) component. - -```svelte - - - - - First - - First accordion content - - - - Second - - Second accordion content - - -``` - -## Adding Styles - -Bits UI components are headless by design, meaning they ship with minimal styling. This gives you complete control over the appearance of your components. Each component that renders an HTML element exposes a `class` prop and `style` prop that you can use to apply styles to the element. - -### Styling with TailwindCSS or UnoCSS - -If you're using a CSS framework like TailwindCSS or UnoCSS, you can pass the classes directly to the components: - -```svelte - - - - - - Tailwind-styled Accordion - - - - - - - This accordion is styled using Tailwind CSS classes. - - - -``` - -### Styling with Data Attributes - -Each Bits UI component applies specific data attributes to the underlying HTML elements. You can use these attributes to target components in your global styles: - -1. - Check the API Reference for each component to determine its data attributes -2. - Use those attributes in your CSS selectors - -+layout.svelte - -```svelte - -Click me -``` - -app.css - -```css -[data-button-root] { - height: 3rem; - width: 100%; - background-color: #3182ce; - color: white; - border-radius: 0.375rem; - padding: 0.5rem 1rem; - font-weight: 500; -} -[data-button-root]:hover { - background-color: #2c5282; -} -``` - -With this approach, every `Button.Root` component will have these styles applied to it automatically. - -## TypeScript Support - -Bits UI is built with TypeScript and provides comprehensive type definitions. When using TypeScript, you'll get full type checking and autocompletion: - -```svelte - -``` - -## Next Steps - -Now that you have Bits UI installed and working, you can: - -- Explore the [Component Documentation](/docs/components) - to learn about all available components -- Learn about render delegation using the [Child Snippet](/docs/child-snippet) - for maximum flexibility and customization -- Learn how Bits UI handles [State Management](/docs/state-management) - and how you can take more control over your components - -## Resources - -If you have questions or need help, there are several ways to get support from the Bits UI community: - -- For confirmed bugs, please [open an issue](https://github.com/huntabyte/bits-ui/issues) - on GitHub. -- Have a question or need help? Join our [Discord community](https://discord.gg/fdXy3Sk8Gq) - or - [open a discussion](https://github.com/huntabyte/bits-ui/discussions/new) - on GitHub to chat with other developers and the Bits UI team. -- Have a feature request or idea? [Open a discussion](https://github.com/huntabyte/bits-ui/discussions/new?category=feature-requests-ideas) - on GitHub to share your thoughts. All feature requests start as discussions before formally being moved to issues. - ----------------------------------------------------- - -# LLMs Documentation - -How to access LLM-friendly versions of Bits UI documentation. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -At the top of each documentation page, you'll find a convenient "Copy Markdown" button alongside a direct link to the LLM-friendly version of that page (e.g., `/llms.txt`). These tools make it easy to copy the content in Markdown format or access the machine-readable `llms.txt` file tailored for that specific page. - -Bits UI documentation is designed to be accessible not only to humans but also to large language models (LLMs). We've adopted the [llms.txt](https://llmstxt.org/) proposal standard, which provides a structured, machine-readable format optimized for LLMs. This enables developers, researchers, and AI systems to efficiently parse and utilize our documentation. - -## What is llms.txt? - -The `llms.txt` standard is an emerging convention for presenting documentation in a simplified, text-based format that's easy for LLMs to process. By following this standard, Bits UI ensures compatibility with AI tools and workflows, allowing seamless integration into LLM-powered applications, research, or automation systems. - -## Accessing LLM-friendly Documentation - -To access the LLM-friendly version of any supported Bits UI documentation page, simply append `/llms.txt` to the end of the page's URL. This will return the content in a plain-text, LLM-optimized format. - -### Example - -- **Standard Page**: The Accordion component documentation is available at [bits-ui.com/docs/components/accordion](https://bits-ui.com/docs/components/accordion) - . -- **LLM-friendly Version**: Append `/llms.txt` to access it at - [bits-ui.com/docs/components/accordion/llms.txt](https://bits-ui.com/docs/components/accordion/llms.txt) - . - -### Root Index - -To explore all supported pages in LLM-friendly format, visit the root index at [bits-ui.com/llms.txt](https://bits-ui.com/llms.txt). This page provides a comprehensive list of available documentation endpoints compatible with the `llms.txt` standard. - -## Full LLM-friendly Documentation - -For a complete, consolidated view of the Bits UI documentation in an LLM-friendly format, navigate to [bits-ui.com/docs/llms.txt](https://bits-ui.com/docs/llms.txt). This single endpoint aggregates all documentation content into a machine-readable structure, ideal for bulk processing or ingestion into AI systems. - -## Notes - -- Not all pages may support the `/llms.txt` suffix (those deemed irrelevant to LLMs, such as the Figma page). Check the root - [bits-ui.com/llms.txt](https://bits-ui.com/llms.txt) - page for an up-to-date list of compatible URLs. -- The "Copy Markdown" button at the top of each page provides the same content you'd find in the `/llms.txt` of that page. - -By embracing the `llms.txt` standard, Bits UI empowers both human developers and AI systems to make the most of our documentation. Whether you're building with Bits UI or training an LLM, these tools are designed to enhance your experience. - ----------------------------------------------------- - -# Ref Documentation - -Learn about the $bindable ref prop. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `ref` prop provides direct access to the underlying HTML elements in Bits UI components, enabling you to manipulate the DOM when necessary. - -## Basic Usage - -Every Bits UI component that renders an HTML element exposes a `ref` prop that you can bind to access the rendered element. This is particularly useful for programmatically manipulating the element, such as focusing inputs or measuring dimensions. - -```svelte - - -Trigger content -``` - -## With Child Snippet - -Bits UI uses element IDs to track references to underlying elements. This approach ensures that the `ref` prop works correctly even when using the [child snippet](/docs/child-snippet). - -### Simple Delegation Example - -The `ref` binding will automatically work with delegated child elements/components. - -```svelte - - - {#snippet child({ props })} - - {/snippet} - -``` - -### Using Custom IDs - -When you need to use a custom `id` on the element, pass it to the parent component first so it can be correctly registered with the `ref` binding: - -```svelte - - - {#snippet child({ props })} - - {/snippet} - -``` - -### Common Pitfalls - -Avoid setting the `id` directly on the child component/element, as this breaks the connection between the `ref` binding and the element: - -```svelte - - {#snippet child({ props })} - - {/snippet} - -``` - -In this example, the `Accordion.Trigger` component can't track the element because it doesn't know the custom ID. - -## Why Possibly `null`? - -The `ref` value may be `null` until the component mounts in the DOM. This behavior is consistent with native DOM methods like `getElementById` which can return `null`. - -## Creating Your Own `ref` Props - -To implement the same ref pattern in your custom components, Bits UI provides a [WithElementRef](/docs/type-helpers/with-element-ref) type helper. This enables you to create type-safe components that follow the same pattern. - -```svelte - - -``` - ----------------------------------------------------- - -# State Management Documentation - -How to manage the state of Bits UI components - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -State management is a critical aspect of modern UI development. Bits UI components support multiple approaches to manage component state, giving you flexibility based on your specific needs. - -Each component's API reference will highlight what props are `bindable`. You can replace the `value` prop used in the examples on this page with any `bindable` prop. - -## Two-Way Binding - -The simplest approach is using Svelte's built-in two-way binding with `bind:`: - -```svelte - - - -``` - -### Why Use It? - -- Zero-boilerplate state updates -- External controls work automatically -- Great for simple use cases ## Function Binding - -For complete control, use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) that handles both getting and setting values: - -```svelte - - -``` - -When the component wants to set the value from an internal action, it will invoke the setter, where you can determine if the setter actually updates the state or not. - -### When to Use - -- Complex state transformation logic -- Conditional updates -- Debouncing or throttling state changes -- Maintaining additional state alongside the primary value -- Integrating with external state systems - ----------------------------------------------------- - -# Styling Documentation - -Learn how to style Bits UI components. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -We ship almost zero styles with Bits UI by design, giving you complete flexibility when styling your components. For each component that renders an HTML element, we expose the `class` and `style` props to apply styles directly to the component. - -## Styling Approaches - -### CSS Frameworks - -If you're using a CSS framework like [TailwindCSS](https://tailwindcss.com/) or [UnoCSS](https://unocss.dev), simply pass the classes to the component: - -```svelte - -Click me -``` - -### Data Attributes - -Each Bits UI component applies specific data attributes to its rendered elements. These attributes provide reliable selectors for styling across your application. - -app.css - -```css -/* Target all Accordion.Trigger components */ -[data-accordion-trigger] { - height: 3rem; - width: 100%; - background-color: #3182ce; - color: #fff; -} -``` - -Import your stylesheet in your layout component: - -+layout.svelte - -```svelte - -{@render children()} -``` - -Now every `Accordion.Trigger` component will have the styles applied to it. - -### Global Classes - -Alternatively, you can use global class names: - -app.css - -```css -.accordion-trigger { - height: 3rem; - width: 100%; - background-color: #3182ce; - color: #fff; -} -``` - -Import your stylesheet in your layout component: - -+layout.svelte - -```svelte - -{@render children()} -``` - -Use the global class with the component: - -```svelte - -Click me -``` - -### Scoped Styles - -To use Svelte's scoped styles, use the `child` snippet to bring the element into your component's scope. See the [Child Snippet](/docs/child-snippet) documentation for more information. - -MyAccordionTrigger.svelte - -```svelte - - - {#snippet child({ props })} - - {/snippet} - - -``` - -### Style Prop - -All Bits UI components that render an element accept a style prop as either a string or an object of CSS properties. These are merged with internal styles using the [`mergeProps`](/docs/utilities/merge-props) function. - -```svelte - - Click me - - - - Click me - -``` - -## Styling Component States - -Bits UI components may expose state information through data attributes and CSS variables, allowing you to create dynamic styles based on component state. - -### State Data Attributes - -Components apply state-specific data attributes that you can target in your CSS: - -```css -/* Style the Accordion.Trigger when open */ -[data-accordion-trigger][data-state="open"] { - background-color: #f0f0f0; - font-weight: bold; -} -/* Style the Accordion.Trigger when closed */ -[data-accordion-trigger][data-state="closed"] { - background-color: #ffffff; -} -/* Style disabled components */ -[data-accordion-trigger][data-disabled] { - opacity: 0.5; - cursor: not-allowed; -} -``` - -See each component's API reference for its specific data attributes. - -### CSS Variables - -Bits UI components may expose CSS variables that allow you to access internal component values. For example, to ensure the `Select.Content` is the same width as the anchor (by default is the `Select.Trigger` unless using a `customAnchor`), you can use the `--bits-select-anchor-width` CSS variable: - -```css -[data-select-content] { - width: var(--bits-select-anchor-width); - min-width: var(--bits-select-anchor-width); - max-width: var(--bits-select-anchor-width); -} -``` - -See each component's API reference for specific CSS variables it provides. - -### Example: Styling an Accordion - -Here's an example styling an accordion with different states: - -```svelte - - - - Section 1 - Content for section 1 - - - Section 2 (Disabled) - Content for section 2 - - - -``` - -## Advanced Styling Techniques - -### Combining Data Attributes with CSS Variables - -You can combine data attributes with CSS variables to create dynamic styles based on component state. Here's how to animate the accordion content using the `--bits-accordion-content-height` variable and the `data-state` attribute: - -```css -/* Basic transition animation */ -[data-accordion-content] { - overflow: hidden; - transition: height 300ms ease-out; - height: 0; -} -[data-accordion-content][data-state="open"] { - height: var(--bits-accordion-content-height); -} -[data-accordion-content][data-state="closed"] { - height: 0; -} -``` - -### Custom Keyframe Animations - -For more control, use keyframe animations with the CSS variables: - -```css -/* Define keyframes for opening animation */ -@keyframes accordionOpen { - 0% { - height: 0; - opacity: 0; - } - 80% { - height: var(--bits-accordion-content-height); - opacity: 0.8; - } - 100% { - height: var(--bits-accordion-content-height); - opacity: 1; - } -} -/* Define keyframes for closing animation */ -@keyframes accordionClose { - 0% { - height: var(--bits-accordion-content-height); - opacity: 1; - } - 20% { - height: var(--bits-accordion-content-height); - opacity: 0.8; - } - 100% { - height: 0; - opacity: 0; - } -} -/* Apply animations based on state */ -[data-accordion-content][data-state="open"] { - animation: accordionOpen 400ms cubic-bezier(0.16, 1, 0.3, 1) forwards; -} -[data-accordion-content][data-state="closed"] { - animation: accordionClose 300ms cubic-bezier(0.7, 0, 0.84, 0) forwards; -} -``` - -### Example: Animated Accordion - -Here's an example of an accordion with a custom transition: - -```svelte - - - - Section 1 - Content for section 1 - - - Section 2 - Content for section 2 - - - -``` - ----------------------------------------------------- - -# Transitions Documentation - -Learn how to use transitions with Bits UI components. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -Svelte Transitions are one of the awesome features of Svelte. Unfortunately, they don't play very nicely with components, due to the fact that they rely on various directives like `in:`, `out:`, and `transition:`, which aren't supported by components. - -In previous version of Bits UI, we had a workaround for this by exposing a ton of `transition*` props on the components that we felt were most likely to be used with transitions. However, this was a bit of a hack and limited us to *only* Svelte Transitions, and users who wanted to use other libraries or just CSS were left out. - -With Bits UI for Svelte 5, we've completely removed this workaround and instead exposed props and snippets that allow you to use any animation or transitions library you want. - -## The Defaults - -By default, Bits UI components handle the mounting and unmounting of specific components for you. They are wrapped in a component that ensures the component waits for transitions to finish before unmounting. - -You can use any CSS transitions or animations you want with this approach, which is what we're doing in the various example components in this documentation, using [tailwindcss-animate](https://github.com/jamiebuilds/tailwindcss-animate). - -## Force Mounting - -On each component that we conditionally render, a `forceMount` prop is exposed. If set to `true`, the component will be forced to mount in the DOM and become visible to the user. You can use this prop in conjunction with the [delegated](/docs/child-snippet) `child` snippet to conditionally render the component and apply Svelte Transitions or another animation library. - -The `child` snippet exposes a prop that you can use to conditionally render the element and apply your transitions. - -```svelte - - - - {#snippet child({ props, open })} - {#if open} -
-
- {/if} - {/snippet} -
-
-``` - -In the example above, we're using the `forceMount` prop to tell the component to forcefully mount the `Dialog.Content` component. We're then using the `child` snippet to delegate the rendering of the `Dialog.Content` to a `div` element which we can apply our props and transitions to. - -We understand this isn't the prettiest syntax, but it enables us to cover every use case. If you intend to use this approach across your application, it's recommended to create a reusable component that handles this logic, like so: - -MyDialogContent.svelte - -```svelte - - - {#snippet child({ props, open })} - {#if open} -
- {@render children?.()} -
- {/if} - {/snippet} -
-``` - -Which can then be used alongside the other `Dialog.*` components: - -```svelte - - - Open Dialog - - - - Dialog Title - Dialog Description - Close -
Other dialog content
-
-
-
-``` - -### Floating Content Components `Content` components that rely on Floating UI require a slight modification to how the `child` snippet is used. - -For example, if we were to use the `Popover.Content` component, we need to add a wrapper element within the `child` snippet, and spread the `wrapperProps` snippet prop to it. - -```svelte - - - Open Popover - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
-
- {/if} - {/snippet} -
-
-
-``` - ----------------------------------------------------- - -# Accordion Documentation - -Organizes content into collapsible sections, allowing users to focus on one or more sections at a time. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - {#each items as item (item.value)} - - - - - {item.title} - - - - - - - -
- {item.content} -
-
-
- {/each} -
-``` - -## Overview - -The Accordion component is a versatile UI element designed to organize content into collapsible sections, helping users focus on specific information without being overwhelmed by visual clutter. - -## Quick Start - -```svelte - - - - - Item 1 Title - - This is the collapsible content for this section. - - - - Item 2 Title - - This is the collapsible content for this section. - - -``` - -## Key Features - -- **Single or Multiple Mode**: Toggle between allowing one open section or multiple sections at once. -- **Accessible by Default**: Built-in ARIA attributes and keyboard navigation support. -- **Smooth Transitions**: Leverage CSS variables or Svelte transitions for animated open/close effects. -- **Flexible State**: Use uncontrolled defaults or take full control with bound values. ## Structure - -The Accordion is a compound component made up of several parts: - -- `Accordion.Root` : Container that manages overall state -- `Accordion.Item` : Individual collapsible section -- `Accordion.Header` : Contains the visible heading -- `Accordion.Trigger` : The clickable element that toggles content visibility -- `Accordion.Content` : The collapsible body content - -## Reusable Components - -To streamline usage in larger applications, create custom wrapper components for repeated patterns. Below is an example of a reusable `MyAccordionItem` and `MyAccordion`. - -### Item Wrapper - -Combines `Item`, `Header`, `Trigger`, and `Content` into a single component: - -MyAccordionItem.svelte - -```svelte - - - - {item.title} - - - {content} - - -``` - -### Accordion Wrapper - -Wraps `Root` and renders multiple `MyAccordionItem` components: - -MyAccordion.svelte - -```svelte - - - - {#each items as item, i (item.title + i)} - - {/each} - -``` - -### Usage Example - -+page.svelte - -```svelte - - -``` - -##### Tip - -Use unique `value` props for each `Item` if you plan to control the state programatically. - -## Managing Value State - -This section covers how to manage the `value` state of the Accordion. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - - - - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -See the [State Management](/docs/state-management) documentation for more information. - -## Customization - -### Single vs. Multiple - -Set the `type` prop to `"single"` to allow only one accordion item to be open at a time. - -```svelte - -``` - -Set the `type` prop to `"multiple"` to allow multiple accordion items to be open at the same time. - -```svelte - -``` - -### Default Open Items - -Set the `value` prop to pre-open items: - -```svelte - -``` - -### Disable Items - -Disable specific items with the `disabled` prop: - -```svelte - - - - -``` - -### Svelte Transitions - -The Accordion component can be enhanced with Svelte's built-in transition effects or other animation libraries. - -#### Using `forceMount` and `child` Snippets - -To apply Svelte transitions to Accordion components, use the `forceMount` prop in combination with the `child` snippet. This approach gives you full control over the mounting behavior and animation of the `Accordion.Content`. - -```svelte - - {#snippet child({ props, open })} - {#if open} -
- This is the accordion content that will transition in and out. -
- {/if} - {/snippet} -
-``` - -In this example: - -- The `forceMount` prop ensures the components are always in the DOM. -- The `child` snippet provides access to the open state and component props. -- Svelte's `#if` block controls when the content is visible. -- Transition directives ( `transition:fade` and `transition:fly` ) apply the animations. - -```svelte - - - {#each items as item, i} - - - - {item.title} - - - - - - - {#snippet child({ props, open })} - {#if open} -
-
- {item.content} -
-
- {/if} - {/snippet} -
-
- {/each} -
-``` - -#### Best Practices - -For cleaner code and better maintainability, consider creating custom reusable components that encapsulate this transition logic. - -MyAccordionContent.svelte - -```svelte - - - {#snippet child({ props, open })} - {#if open} -
- {@render children?.()} -
- {/if} - {/snippet} -
-``` - -You can then use the `MyAccordionContent` component alongside the other `Accordion` primitives throughout your application: - -```svelte - - - - A - - - - - -``` - -## Examples - -The following examples demonstrate different ways to use the Accordion component. - -### Horizontal Cards - -Use the Accordion component to create a horizontal card layout with collapsible sections. - -```svelte - - - {#each items as item (item.id)} - (value = item.id)} - > - {item.title} -
-
- - - {item.title} - - - - {item.description} - -
-
-
-
- {/each} -
-``` - -### Checkout Steps - -Use the Accordion component to create a multi-step checkout process. - -```svelte - -{#snippet MyAccordionHeader({ value, title }: { value: string; title: string })} - {@const isCompleted = completedSteps.has(value)} - - -
- {isCompleted ? "" : value} -
- - {title} - - - - -
-
-{/snippet} -{#snippet InputField({ - label, - placeholder, - type = "text" -}: { - label: string; - placeholder: string; - type?: string; -})} - {@const id = useId()} -
- - -
-{/snippet} - - - {@render MyAccordionHeader({ title: "Shipping Information", value: "1" })} - -
-
- {@render InputField({ label: "First Name", placeholder: "John" })} - {@render InputField({ label: "Last Name", placeholder: "Doe" })} -
- {@render InputField({ - label: "Address", - placeholder: "1234 Elm Street" - })} -
- {@render InputField({ label: "City", placeholder: "Tampa" })} - {@render InputField({ label: "ZIP", placeholder: "123456" })} -
-
- { - completedSteps.add("1"); - activeStep = "2"; - }} - > - Continue to Payment - -
-
-
-
- - {@render MyAccordionHeader({ title: "Payment Method", value: "2" })} - -
-
-
- {@render InputField({ - label: "Card Number", - placeholder: "4242 4242 4242 4242" - })} -
- {@render InputField({ label: "Exp. Month", placeholder: "MM" })} - {@render InputField({ label: "Exp. Year", placeholder: "YY" })} - {@render InputField({ label: "CVC", placeholder: "123" })} -
-
- { - completedSteps.add("2"); - activeStep = "3"; - }} - > - Continue to Review - -
-
-
-
- - {@render MyAccordionHeader({ title: "Payment Method", value: "3" })} - -
-
-

Order Summary

-
-
- Product 1 - $29.99 -
-
- Product 2 - $49.99 -
-
- Shipping - $4.99 -
-
- Total - $84.97 -
-
-
-
- { - completedSteps.add("3"); - activeStep = ""; - }} - > - Place Order - -
-
-
-
-
-``` - -## API Reference - -### Accordion.Root - -The root accordion component used to set and manage the state of the accordion. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `enum`- 'single' \| 'multiple' | The type of accordion. If set to `'multiple'`, the accordion will allow multiple items to be open at the same time. If set to `single`, the accordion will only allow a single item to be open.`Default: undefined` | -| `value` $bindable | `union`- string\[] \| string | The value of the currently active accordion item. If `type` is `'single'`, this should be a string. If `type` is `'multiple'`, this should be an array of strings.`Default: undefined` | -| `onValueChange` | `function`- string \| string\[] | A callback function called when the active accordion item value changes. If the `type` is `'single'`, the argument will be a string. If `type` is `'multiple'`, the argument will be an array of strings.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the accordion is disabled. When disabled, the accordion cannot be interacted with.`Default: false` | -| `loop` | `boolean` | Whether or not the accordion should loop through items when reaching the end.`Default: false` | -| `orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the accordion.`Default: vertical` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-disabled` | `''` | Present when the component is disabled. | -| `data-accordion-root` | `''` | Present on the root element. | - -### Accordion.Item - -An accordion item. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the accordion item is disabled.`Default: false` | -| `value` | `string` | The value of the accordion item. This is used to identify when the item is open or closed. If not provided, a unique ID will be generated for this value.`Default: A random unique ID` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the accordion item is open or closed. | -| `data-disabled` | `''` | Present when the component is disabled. | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-accordion-item` | `''` | Present on the item element. | - -### Accordion.Header - -The header of the accordion item. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `level` | `union`- 1 \| 2 \| 3 \| 4 \| 5 \| 6 | The heading level of the header. This will be set as the `aria-level` attribute.`Default: 3` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-disabled` | `''` | Present when the component is disabled. | -| `data-heading-level` | `enum`- '1' \| '2' \| '3' \| '4' \| '5' \| '6' | The heading level of the element. | -| `data-accordion-header` | `''` | Present on the header element. | - -### Accordion.Trigger - -The button responsible for toggling the accordion item. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-disabled` | `''` | Present when the component is disabled. | -| `data-accordion-trigger` | `''` | Present on the trigger element. | - -### Accordion.Content - -The accordion item content, which is displayed when the item is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-disabled` | `''` | Present when the component is disabled. | -| `data-accordion-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-accordion-content-height` | The height of the accordion content element. | -| `--bits-accordion-content-width` | The width of the accordion content element. | - ----------------------------------------------------- - -# Alert Dialog Documentation - -A modal window that alerts users with important information and awaits their acknowledgment or action. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - Subscribe - - - - -
- - Confirm your transaction - - - This action cannot be undone. This will initiate a monthly wire in the - amount of $10,000 to Huntabyte. Do you wish to continue? - -
-
- - Cancel - - - Continue - -
-
-
-
-``` - -## Key Features - -- **Compound Component Structure**: Build flexible, customizable alert dialogs using sub-components. -- **Accessibility**: ARIA-compliant with full keyboard navigation support. -- **Portal Support**: Render content outside the normal DOM hierarchy for proper stacking. -- **Managed Focus**: Automatically traps focus with customization options. -- **Flexible State**: Supports both controlled and uncontrolled open states. ## Structure - -The Alert Dialog is built from sub-components, each with a specific purpose: - -- **Root**: Manages state and provides context to child components. -- **Trigger**: Toggles the dialog's open/closed state. -- **Portal**: Renders its children in a portal, outside the normal DOM hierarchy. -- **Overlay**: Displays a backdrop behind the dialog. -- **Content**: Holds the dialog's main content. -- **Title**: Displays the dialog's title. -- **Description**: Displays a description or additional context for the dialog. -- **Cancel**: Closes the dialog without action. -- **Action**: Confirms the dialog's action. Here's a simple example of an Alert Dialog: - -```svelte - - - Open Dialog - - - - Confirm Action - Are you sure? - Cancel - Confirm - - - -``` - -## Reusable Components - -For consistency across your app, create a reusable Alert Dialog component. Here's an example: - -MyAlertDialog.svelte - -```svelte - - - - {buttonText} - - - - - - {@render title()} - - - {@render description()} - - {@render children?.()} - Cancel - Confirm - - - -``` - -You can then use the `MyAlertDialog` component in your application like so: - -+page.svelte - -```svelte - - - {#snippet title()} - Delete your account - {/snippet} - {#snippet description()} - This action cannot be undone. - {/snippet} - -``` - -Alternatively, you can define the snippets separately and pass them as props to the component: - -+page.svelte - -```svelte - -{#snippet title()} - Delete your account -{/snippet} -{#snippet description()} - This action cannot be undone. -{/snippet} - - -``` - -##### Tip - -Use string props for simplicity or snippets for dynamic content. - -## Managing Open State - -This section covers how to manage the `open` state of the Alert Dialog. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for total control: - -```svelte - - - -``` - -See the [State Management](/docs/state-management) documentation for more information. - -## Focus Management - -### Focus Trap - -Focus is trapped within the dialog by default. To disable: - -```svelte - - -``` - -##### Warning - -Disabling focus trap may reduce accessibility. Use with caution. - -### Open Focus - -By default, when a dialog is opened, focus will be set to the `AlertDialog.Cancel` button if it exists, or the first focusable element within the `AlertDialog.Content`. This ensures that users navigating my keyboard end up somewhere within the Dialog that they can interact with. - -You can override this behavior using the `onOpenAutoFocus` prop on the `AlertDialog.Content` component. It's *highly* recommended that you use this prop to focus *something* within the Dialog. - -You'll first need to cancel the default behavior of focusing the first focusable element by cancelling the event passed to the `onOpenAutoFocus` callback. You can then focus whatever you wish. - -```svelte - - - Open AlertDialog - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - - -``` - -### Close Focus - -By default, when a dialog is closed, focus will be set to the trigger element of the dialog. You can override this behavior using the `onCloseAutoFocus` prop on the `AlertDialog.Content` component. - -You'll need to cancel the default behavior of focusing the trigger element by cancelling the event passed to the `onCloseAutoFocus` callback, and then focus whatever you wish. - -```svelte - - - - Open AlertDialog - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - -``` - -## Advanced Behaviors - -The Alert Dialog component offers several advanced features to customize its behavior and enhance user experience. This section covers scroll locking, escape key handling, and interaction outside the dialog. - -### Scroll Lock - -By default, when an Alert Dialog opens, scrolling the body is disabled. This provides a more native-like experience, focusing user attention on the dialog content. - -#### Customizing Scroll Behavior - -To allow body scrolling while the dialog is open, use the `preventScroll` prop on `AlertDialog.Content`: - -```svelte - - -``` - -##### Note - -Enabling body scroll may affect user focus and accessibility. Use this option judiciously. - -### Escape Key Handling - -By default, pressing the `Escape` key closes an open Alert Dialog. Bits UI provides two methods to customize this behavior. - -#### Method 1: `escapeKeydownBehavior` The `escapeKeydownBehavior` prop allows you to customize the behavior taken by the component when the `Escape` key is pressed. It accepts one of the following values: - -- `'close'` (default): Closes the Alert Dialog immediately. -- `'ignore'` : Prevents the Alert Dialog from closing. -- `'defer-otherwise-close'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Alert Dialog will close immediately. -- `'defer-otherwise-ignore'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Alert Dialog will ignore the key press and not close. - -To always prevent the Alert Dialog from closing on Escape key press, set the `escapeKeydownBehavior` prop to `'ignore'` on `Dialog.Content`: - -```svelte - - -``` - -#### Method 2: `onEscapeKeydown` For more granular control, override the default behavior using the `onEscapeKeydown` prop: - -```svelte - { - e.preventDefault(); - // do something else instead - }} -> - -``` - -This method allows you to implement custom logic when the `Escape` key is pressed. - -### Interaction Outside - -By default, interacting outside the Alert Dialog content area does not close the dialog. Bits UI offers two ways to modify this behavior. - -#### Method 1: `interactOutsideBehavior` The `interactOutsideBehavior` prop allows you to customize the behavior taken by the component when an interaction (touch, mouse, or pointer event) occurs outside the content. It accepts one of the following values: - -- `'ignore'` (default): Prevents the Alert Dialog from closing. -- `'close'` : Closes the Alert Dialog immediately. -- `'defer-otherwise-close'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Alert Dialog will close immediately. -- `'defer-otherwise-ignore'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Alert Dialog will ignore the event and not close. - -To make the Alert Dialog close when an interaction occurs outside the content, set the `interactOutsideBehavior` prop to `'close'` on `AlertDialog.Content`: - -```svelte - - -``` - -#### Method 2: `onInteractOutside` For custom handling of outside interactions, you can override the default behavior using the `onInteractOutside` prop: - -```svelte - { - e.preventDefault(); - // do something else instead - }} -> - -``` - -This approach allows you to implement specific behaviors when users interact outside the Alert Dialog content. - -### Best Practices - -- **Scroll Lock**: Consider your use case carefully before disabling scroll lock. It may be necessary for dialogs with scrollable content or for specific UX requirements. -- **Escape Keydown**: Overriding the default escape key behavior should be done thoughtfully. Users often expect the escape key to close modals. -- **Outside Interactions**: Ignoring outside interactions can be useful for important dialogs or multi-step processes, but be cautious not to trap users unintentionally. -- **Accessibility**: Always ensure that any customizations maintain or enhance the dialog's accessibility. -- **User Expectations**: Try to balance custom behaviors with common UX patterns to avoid confusing users. By leveraging these advanced features, you can create highly customized dialog experiences while maintaining usability and accessibility standards. - -## Nested Dialogs - -Dialogs can be nested within each other to create more complex layouts. See the [Dialog](/docs/components/dialog) component for more information on nested dialogs. - -## Svelte Transitions - -See the [Dialog](/docs/components/dialog) component for more information on Svelte Transitions with dialog components. - -## Working with Forms - -### Form Submission - -When using the `AlertDialog` component, often you'll want to submit a form or perform an asynchronous action when the user clicks the `Action` button. - -This can be done by waiting for the asynchronous action to complete, then programmatically closing the dialog. - -```svelte - - - - - - Confirm your action - Are you sure you want to do this? -
{ - wait(1000).then(() => (open = false)); - }} - > - No, cancel (close dialog) - Yes (submit form) -
-
-
-
-``` - -### Inside a Form - -If you're using an `AlertDialog` *within* a form, you'll need to ensure that the `Portal` is disabled or not included in the `AlertDialog` structure. This is because the `Portal` will render the dialog content *outside* of the form, which will prevent the form from being submitted correctly. - -## API Reference - -### AlertDialog.Root - -The root component used to set and manage the state of the alert dialog. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | Whether or not the alert dialog is open.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback function called when the open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### AlertDialog.Trigger - -The element which opens the alert dialog on press. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The state of the alert dialog. | -| `data-alert-dialog-trigger` | `''` | Present on the trigger element. | - -### AlertDialog.Content - -The content displayed within the alert dialog modal. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `restoreScrollDelay` | `number` | The delay in milliseconds before the scrollbar is restored after closing the dialog. This is only applicable when using the `child` snippet for custom transitions and `preventScroll` and `forceMount` are `true`. You should set this to a value greater than the transition duration to prevent content from shifting during the transition.`Default: null` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The state of the alert dialog. | -| `data-alert-dialog-content` | `''` | Present on the content element. | - -### AlertDialog.Overlay - -An overlay which covers the body when the alert dialog is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The state of the alert dialog. | -| `data-alert-dialog-overlay` | `''` | Present on the overlay element. | - -### AlertDialog.Portal - -A portal which renders the alert dialog into the body when it is open. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### AlertDialog.Action - -The button responsible for taking an action within the alert dialog. This button does not close the dialog out of the box. See the [Form Submission](#form-submission) documentation for more information. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-alert-dialog-action` | `''` | Present on the action element. | - -### AlertDialog.Cancel - -A button used to close the alert dialog without taking an action. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-alert-dialog-cancel` | `''` | Present on the cancel element. | - -### AlertDialog.Title - -An accessible title for the alert dialog. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `level` | `union`- 1 \| 2 \| 3 \| 4 \| 5 \| 6 | The heading level of the title. This will be set as the `aria-level` attribute.`Default: 3` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-alert-dialog-title` | `''` | Present on the title element. | - -### AlertDialog.Description - -An accessible description for the alert dialog. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-alert-dialog-description` | `''` | Present on the description element. | - ----------------------------------------------------- - -# Aspect Ratio Documentation - -Displays content while maintaining a specified aspect ratio, ensuring consistent visual proportions. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - an abstract painting - -``` - -## Architecture - -- **Root**: The root component which contains the aspect ratio logic ## Structure - -Here's an overview of how the Aspect Ratio component is structured in code: - -```svelte - - -``` - -## Reusable Component - -If you plan on using a lot of `AspectRatio` components throughout your application, you can create a reusable component that combines the `AspectRatio.Root` and whatever other elements you'd like to render within it. In the following example, we're creating a reusable `MyAspectRatio` component that takes in a `src` prop and renders an `img` element with the `src` prop. - -MyAspectRatio.svelte - -```svelte - - - - -``` - -You can then use the `MyAspectRatio` component in your application like so: - -+page.svelte - -```svelte - - -``` - -## Custom Ratio - -Use the `ratio` prop to set a custom aspect ratio for the image. - -```svelte - - -``` - -## API Reference - -### AspectRatio.Root - -The aspect ratio component. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ratio` | `number` | The desired aspect ratio.`Default: 1` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-aspect-ratio-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Avatar Documentation - -Represents a user or entity with a recognizable image or placeholder in UI elements. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
- - HB -
-
-``` - -## Overview - -The Avatar component provides a consistent way to display user or entity images throughout your application. It handles image loading states gracefully and offers fallback options when images fail to load, ensuring your UI remains resilient. - -## Features - -- **Smart Image Loading**: Automatically detects and handles image loading states -- **Fallback System**: Displays alternatives when images are unavailable or slow to load -- **Compound Structure**: Flexible primitives that can be composed and customized -- **Customizable**: Choose to show the image immediately without a load check when you're certain the image will load. ## Architecture - -The Avatar component follows a compound component pattern with three key parts: - -- **Avatar.Root**: Container that manages the state of the image and its fallback -- **Avatar.Image**: Displays user or entity image -- **Avatar.Fallback**: Shows when the image is loading or fails to load ## Quick Start - -To get started with the Avatar component, you can use the `Avatar.Root`, `Avatar.Image`, and `Avatar.Fallback` primitives to create a basic avatar component: - -```svelte - - - - HB - -``` - -## Reusable Components - -You can create your own reusable Avatar component to maintain consistent styling and behavior throughout your application: - -UserAvatar.svelte - -```svelte - - - - - {fallback} - - -``` - -Then use it throughout your application: - -+page.svelte - -```svelte - -{#each users as user} - -{/each} -``` - -## Customization - -### Skip Loading Check - -When you're confident that an image will load (such as local assets), you can bypass the loading check: - -```svelte - - - - HB - -``` - -## Examples - -### Clickable with Link Preview - -This example demonstrates how to create a clickable avatar composed with a [Link Preview](/docs/components/link-preview): - -```svelte - - - - -
- - HB -
-
-
- -
- -
- - HB -
-
-
-

@huntabyte

-

I do things on the internet.

-
-
- - FL, USA -
-
- - Joined May 2020 -
-
-
-
-
-
-``` - -## API Reference - -### Avatar.Root - -The root component used to set and manage the state of the avatar. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadingStatus` $bindable | `enum`- 'loading' \| 'loaded' \| 'error' | The loading status of the avatars source image. You can bind a variable to track the status outside of the component and use it to show a loading indicator or error message.`Default: undefined` | -| `onLoadingStatusChange` | `function`- (status: LoadingStatus) => void | A callback function called when the loading status of the image changes.`Default: undefined` | -| `delayMs` | `number` | How long to wait before showing the image after it has loaded. This can be useful to prevent a harsh flickering effect when the image loads quickly.`Default: 0` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-status` | `enum`- 'loading' \| 'loaded' \| 'error' | The loading status of the image. | -| `data-avatar-root` | `''` | Present on the root element. | - -### Avatar.Image - -The avatar image displayed once it has loaded. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLImageElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-status` | `enum`- 'loading' \| 'loaded' \| 'error' | The loading status of the image. | -| `data-avatar-image` | `''` | Present on the root element. | - -### Avatar.Fallback - -The fallback displayed while the avatar image is loading or if it fails to load - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-status` | `enum`- 'loading' \| 'loaded' \| 'error' | The loading status of the image. | -| `data-avatar-fallback` | `''` | Present on the fallback element. | - ----------------------------------------------------- - -# Button Documentation - -A component that if passed a `href` prop will render an anchor element instead of a button element. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - Unlimited - -``` - -## Structure - -```svelte - - -``` - -## API Reference - -### Button.Root - -A component that can switch between a button and an anchor tag based on the `href`/`type` props. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `href` | `string` | An optional prop that when passed converts the button into an anchor tag.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the button is disabled. When disabled, the button cannot be interacted with.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -| Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-button-root` | `''` | Present on the button element. | - ----------------------------------------------------- - -# Calendar Documentation - -Displays dates and days of the week, facilitating date-related interactions. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - {#snippet children({ months, weekdays })} - - - - - - - - - -
- {#each months as month, i (i)} - - - - {#each weekdays as day} - -
{day.slice(0, 2)}
-
- {/each} -
-
- - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
- {/each} -
- {/snippet} -
-``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Structure - -```svelte - - - {#snippet children({ months, weekdays })} - - - - - - {#each months as month} - - - - {#each weekdays as day} - - {day} - - {/each} - - - - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {/each} - - {/each} - - - {/each} - {/snippet} - -``` - -## Placeholder - -The `placeholder` prop for the `Calendar.Root` component determines what date our calendar should start with when the user hasn't selected a date yet. It also determines the current "view" of the calendar. - -As the user navigates through the calendar, the `placeholder` will be updated to reflect the currently focused date in that view. - -By default, the `placeholder` will be set to the current date, and be of type `CalendarDate`. - -## Managing Placeholder State - -This section covers how to manage the `placeholder` state of the Calendar. - -### Two-Way Binding - -Use `bind:placeholder` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -See the [State Management](/docs/state-management) documentation for more information. - -## Managing Value State - -This section covers how to manage the `value` state of the Calendar. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -See the [State Management](/docs/state-management) documentation for more information. - -## Default Value - -Often, you'll want to start the `Calendar.Root` component with a default value. Likely this value will come from a database in the format of an ISO 8601 string. - -You can use the `parseDate` function from the `@internationalized/date` package to parse the string into a `CalendarDate` object. - -```svelte - - - -``` - -## Validation - -### Minimum Value - -You can set a minimum value for the calendar by using the `minValue` prop on `Calendar.Root`. If a user selects a date that is less than the minimum value, the calendar will be marked as invalid. - -```svelte - - - -``` - -### Maximum Value - -You can set a maximum value for the calendar by using the `maxValue` prop on `Calendar.Root`. If a user selects a date that is greater than the maximum value, the calendar will be marked as invalid. - -```svelte - - - -``` - -### Unavailable Dates - -You can specify specific dates that are unavailable for selection by using the `isDateUnavailable` prop. This prop accepts a function that returns a boolean value indicating whether a date is unavailable or not. - -```svelte - - - -``` - -### Disabled Dates - -You can specify specific dates that are disabled for selection by using the `isDateDisabled` prop. - -```svelte - - - -``` - -## Appearance & Behavior - -### Fixed Weeks - -You can use the `fixedWeeks` prop to ensure that the calendar renders a fixed number of weeks, regardless of the number of days in the month. This is useful to keep the calendar visually consistent when the number of days in the month changes. - -```svelte - - -``` - -### Multiple Months - -You can use the `numberOfMonths` prop to render multiple months at once. - -```svelte - - -``` - -### Paged Navigation - -By default, when the calendar has more than one month, the previous and next buttons will shift the calendar forward or backward by one month. However, you can change this behavior by setting the `pagedNavigation` prop to `true`, which will shift the calendar forward or backward by the number of months being displayed. - -```svelte - - -``` - -### Localization - -The calendar will automatically format the content of the calendar according to the `locale` prop, which defaults to `'en-US'`, but can be changed to any locale supported by the [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) API. - -```svelte - - -``` - -### Week Starts On - -The calendar will automatically format the content of the calendar according to the `weekStartsOn` prop, which defaults to `0`, but can be changed to any day of the week, where `0` is Sunday and `6` is Saturday. - -```svelte - - -``` - -### Multiple Selection - -You can set the `type` prop to `'multiple'` to allow users to select multiple dates at once. - -```svelte - - -``` - -## Custom Composition - -### Month Selector - -The `Calendar` component includes a `PrevButton` and `NextButton` component to allow users to navigate between months. This is useful, but sometimes you may want to allow the user to select a specific month from a list of months, rather than having to navigate one at a time. - -To achieve this, you can use the `placeholder` prop to set the month of the the calendar view programmatically. - -```svelte - - - - - -``` - -Updating the `placeholder` will update the calendar view to reflect the new month. - -## Examples - -### Month and Year Selects - -This example demonstrates how to use the `placeholder` prop to set the month and year of the calendar view programmatically. - -```svelte - - - {#snippet children({ months, weekdays })} - - - - -
- {#each months as month, i (i)} - - - - {#each weekdays as day} - -
{day.slice(0, 2)}
-
- {/each} -
-
- - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
- {/each} -
- {/snippet} -
-``` - -### Preset Dates - -This example demonstrates how to programatically set the `value` of the calendar to a specific date when a user presses a button. - -```svelte - -
- - {#snippet children({ months, weekdays })} - - - - - - - - - -
- {#each months as month, i (i)} - - - - {#each weekdays as day} - -
{day.slice(0, 2)}
-
- {/each} -
-
- - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
- {/each} -
- {/snippet} -
- -
- {#each presets as preset} - - Set date to - {preset.label} - - {/each} -
-
-``` - -## API Reference - -### Calendar.Root - -The root calendar component which contains all other calendar components. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `type` required | `enum`- 'single' \| 'multiple' | Whether or not multiple dates can be selected.`Default: undefined` | -| `value` $bindable | `union`- DateValue \| DateValue\[] | The selected date(s). If `type` is `'single'`, this will be a `DateValue`. If `type` is `'multiple'`, this will be an array of `DateValue`s.`Default: undefined` | -| `onValueChange` | `function`- (value: DateValue) => void \| (value: DateValue\[]) => void | A function that is called when the selected date changes.`Default: undefined` | -| `placeholder` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what month to display when no date is selected. This updates as the user navigates the calendar, and can be used to programmatically control the calendar's view.`Default: undefined` | -| `onPlaceholderChange` | `function`- (date: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `pagedNavigation` | `boolean` | Whether or not to use paged navigation for the calendar. Paged navigation causes the previous and next buttons to navigate by the number of months displayed at once, rather than by one month.`Default: false` | -| `preventDeselect` | `boolean` | Whether or not to prevent the user from deselecting a date without selecting another date first.`Default: false` | -| `weekStartsOn` | `number` | The day of the week to start the calendar on. 0 is Sunday, 1 is Monday, etc.`Default: 0` | -| `weekdayFormat` | `enum`- 'narrow' \| 'short' \| 'long' | The format to use for the weekday strings provided via the `weekdays` slot prop.`Default: 'narrow'` | -| `calendarLabel` | `string` | The accessible label for the calendar.`Default: undefined` | -| `fixedWeeks` | `boolean` | Whether or not to always display 6 weeks in the calendar.`Default: false` | -| `isDateDisabled` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is disabled.`Default: undefined` | -| `isDateUnavailable` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum date that can be selected.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum date that can be selected.`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en'` | -| `numberOfMonths` | `number` | The number of months to display at once.`Default: 1` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the calendar is readonly.`Default: false` | -| `initialFocus` | `boolean` | If `true`, the calendar will focus the selected day, today, or the first day of the month in that order depending on what is visible when the calendar is mounted.`Default: false` | -| `disableDaysOutsideMonth` | `boolean` | Whether or not to disable days outside the current month.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the root element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the root element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the root element when the calendar is readonly. | -| `data-calendar-root` | `''` | Present on the root element. | - -### Calendar.Header - -The header of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the header element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the header element when the calendar is readonly. | -| `data-calendar-header` | `''` | Present on the header element. | - -### Calendar.Heading - -The heading of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the heading element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the heading element when the calendar is readonly. | -| `data-calendar-heading` | `''` | Present on the heading element. | - -### Calendar.NextButton - -The next button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the next button element when the calendar or this button is disabled. | -| `data-calendar-next-button` | `''` | Present on the next button element. | - -### Calendar.PrevButton - -The previous button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the prev button element when the calendar or this button is disabled. | -| `data-calendar-prev-button` | `''` | Present on the prev button element. | - -### Calendar.Cell - -A cell in the calendar grid. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `date` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The date for the cell.`Default: undefined` | -| `month` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The current month the date is being displayed in.`Default: undefined` | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-cell` | `''` | Present on the cell element. | - -### Calendar.Day - -A day in the calendar grid. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-day` | `''` | Present on the day element. | - -### Calendar.Grid - -The grid of dates in the calendar, typically representing a month. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid` | `''` | Present on the grid element. | - -### Calendar.GridBody - -The body of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid-body` | `''` | Present on the grid body element. | - -### Calendar.GridHead - -The head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid head element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid head element when the calendar is readonly. | -| `data-calendar-grid-head` | `''` | Present on the grid head element. | - -### Calendar.GridRow - -A row in the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableRowElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid row element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid row element when the calendar is readonly. | -| `data-calendar-grid-row` | `''` | Present on the grid row element. | - -### Calendar.HeadCell - -A cell in the head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the head cell element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the head cell element when the calendar is readonly. | -| `data-calendar-head-cell` | `''` | Present on the head cell element. | - ----------------------------------------------------- - -# Checkbox Documentation - -Allow users to switch between checked, unchecked, and indeterminate states. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
- - {#snippet children({ checked, indeterminate })} -
- {#if indeterminate} - - {:else if checked} - - {/if} -
- {/snippet} -
- - Accept terms and conditions - -
-``` - -## Overview - -The Checkbox component provides a flexible and accessible way to create checkbox inputs in your Svelte applications. It supports three states: checked, unchecked, and indeterminate, allowing for complex form interactions and data representations. - -## Key Features - -- **Tri-State Support**: Handles checked, unchecked, and indeterminate states, providing versatility in form design. -- **Accessibility**: Built with WAI-ARIA guidelines in mind, ensuring keyboard navigation and screen reader support. -- **Flexible State Management**: Supports both controlled and uncontrolled state, allowing for full control over the checkbox's checked state. ## Architecture - -The Checkbox component is composed of the following parts: - -- **Root**: The main component that manages the state and behavior of the checkbox. ## Structure - -Here's an overview of how the Checkbox component is structured in code: - -```svelte - - - {#snippet children({ checked, indeterminate })} - {#if indeterminate} - - - {:else if checked} - - {:else} - - {/if} - {/snippet} - -``` - -## Reusable Components - -It's recommended to use the `Checkbox` primitive to create your own custom checkbox component that can be used throughout your application. In the example below, we're using the `Checkbox` and [`Label`](/docs/components/label) components to create a custom checkbox component. - -MyCheckbox.svelte - -```svelte - - - {#snippet children({ checked, indeterminate })} - {#if indeterminate} - - - {:else if checked} - - {:else} - - {/if} - {/snippet} - - - {labelText} - -``` - -You can then use the `MyCheckbox` component in your application like so: - -+page.svelte - -```svelte - - -``` - -## Managing Checked State - -This section covers how to manage the `checked` state of the Checkbox. - -### Two-Way Binding - -Use `bind:checked` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Indeterminate State - -This section covers how to manage the `indeterminate` state of the Checkbox. - -### Two-Way Binding - -Use `bind:indeterminate` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Disabled State - -You can disable the checkbox by setting the `disabled` prop to `true`. - -```svelte - -``` - -## HTML Forms - -If you set the `name` prop, a hidden checkbox input will be rendered to submit the value of the checkbox with a form. - -By default, the checkbox will be submitted with default checkbox value of `'on'` if the `checked` prop is `true`. - -```svelte - -``` - -### Custom Input Value - -If you'd prefer to submit a different value, you can use the `value` prop to set the value of the hidden input. - -For example, if you wanted to submit a string value, you could do the following: - -```svelte - -``` - -### Required - -If you want to make the checkbox required, you can use the `required` prop. - -```svelte - - -``` - -This will apply the `required` attribute to the hidden input element, ensuring that proper form submission is enforced. - -## Checkbox Groups - -You can use the `Checkbox.Group` component to create a checkbox group. - -```svelte - - - Notifications - - - - -``` - -```svelte - - - - Notifications - -
- {@render MyCheckbox({ label: "Marketing", value: "marketing" })} - {@render MyCheckbox({ label: "Promotions", value: "promotions" })} - {@render MyCheckbox({ label: "News", value: "news" })} - {@render MyCheckbox({ label: "Updates", value: "updates" })} -
-
-{#snippet MyCheckbox({ value, label }: { value: string; label: string })} - {@const id = useId()} -
- - {#snippet children({ checked, indeterminate })} -
- {#if indeterminate} - - {:else if checked} - - {/if} -
- {/snippet} -
- - {label} - -
-{/snippet} -``` - -### Managing Value State - -This section covers how to manage the `value` state of a Checkbox Group. - -#### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - Items - - - - -``` - -#### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -### HTML Forms - -To render hidden `` elements for the various checkboxes within a group, pass a `name` to `Checkbox.Group`. All descendent checkboxes will then render hidden inputs with the same name. - -```svelte - - -``` - -When a `Checkbox.Group` component is used, its descendent `Checkbox.Root` components will use certain properties from the group, such as the `name`, `required`, and `disabled`. - -## API Reference - -### Checkbox.Root - -The button component used to toggle the state of the checkbox. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `checked` $bindable | `boolean` | The checkbox button's checked state.`Default: false` | -| `onCheckedChange` | `function`- (checked: boolean) => void | A callback that is fired when the checkbox button's checked state changes.`Default: undefined` | -| `indeterminate` $bindable | `boolean` | Whether the checkbox is an indeterminate state or not.`Default: false` | -| `onIndeterminateChange` | `function`- (indeterminate: boolean) => void | A callback that is fired when the indeterminate state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the checkbox button is disabled. This prevents the user from interacting with it.`Default: false` | -| `required` | `boolean` | Whether or not the checkbox is required.`Default: false` | -| `name` | `string` | The name of the checkbox. If provided a hidden input will be render to use for form submission. If not provided, the hidden input will not be rendered.`Default: undefined` | -| `value` | `string` | The value of the checkbox. This is what is submitted with the form when the checkbox is checked.`Default: undefined` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'checked' \| 'unchecked' \| 'indeterminate' | The checkbox's state of checked, unchecked, or indeterminate. | -| `data-disabled` | `''` | Present when the checkbox is disabled. | -| `data-checkbox-root` | `''` | Present on the root element. | - -### Checkbox.Group - -A group that synchronizes its value state with its descendant checkboxes. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `value` $bindable | `string[]` | The value of the group. This is an array of the values of the checked checkboxes within the group.`Default: []` | -| `onValueChange` | `function`- (value: string\[]) => void | A callback that is fired when the checkbox group's value state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the checkbox group is disabled. If `true`, all checkboxes within the group will be disabled. To disable a specific checkbox in the group, pass the `disabled` prop to the checkbox.`Default: false` | -| `required` | `boolean` | Whether or not the checkbox group is required for form submission.`Default: false` | -| `name` | `string` | The name of the checkbox group. If provided a hidden input will be rendered to use for form submission.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-disabled` | `''` | Present when the checkbox group is disabled. | -| `data-checkbox-group` | `''` | Present on the group element. | - -### Checkbox.GroupLabel - -An accessible label for the checkbox group. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLLabelElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-disabled` | `''` | Present when the checkbox group is disabled. | -| `data-checkbox-group-label` | `''` | Present on the label element. | - ----------------------------------------------------- - -# Collapsible Documentation - -Conceals or reveals content sections, enhancing space utilization and organization. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
-

@huntabyte starred 3 repositories

- - - -
- -
- @huntabyte/bits-ui -
-
- @huntabyte/shadcn-svelte -
-
- @svecosystem/runed -
-
-
-``` - -## Overview - -The Collapsible component enables you to create expandable and collapsible content sections. It provides an efficient way to manage space and organize information in user interfaces, enabling users to show or hide content as needed. - -## Key Features - -- **Accessibility**: ARIA attributes for screen reader compatibility and keyboard navigation. -- **Transition Support**: CSS variables and data attributes for smooth transitions between states. -- **Flexible State Management**: Supports controlled and uncontrolled state, take control if needed. -- **Compound Component Structure**: Provides a set of sub-components that work together to create a fully-featured collapsible. ## Architecture - -The Collapsible component is composed of a few sub-components, each with a specific role: - -- **Root**: The parent container that manages the state and context for the collapsible functionality. -- **Trigger**: The interactive element (e.g., button) that toggles the expanded/collapsed state of the content. -- **Content**: The container for the content that will be shown or hidden based on the collapsible state. ## Structure - -Here's an overview of how the Collapsible component is structured in code: - -```svelte - - - - - -``` - -## Reusable Components - -It's recommended to use the `Collapsible` primitives to create your own custom collapsible component that can be used throughout your application. - -MyCollapsible.svelte - -```svelte - - - {buttonText} - - {@render children?.()} - - -``` - -You can then use the `MyCollapsible` component in your application like so: - -+page.svelte - -```svelte - -Here is my collapsible content. -``` - -## Managing Open State - -This section covers how to manage the `open` state of the Collapsible. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Svelte Transitions - -The Collapsible component can be enhanced with Svelte's built-in transition effects or other animation libraries. - -### Using `forceMount` and `child` Snippets - -To apply Svelte transitions to Collapsible components, use the `forceMount` prop in combination with the `child` snippet. This approach gives you full control over the mounting behavior and animation of the `Collapsible.Content`. - -```svelte - - - Open - - {#snippet child({ props, open })} - {#if open} -
-
- {/if} - {/snippet} -
-
-``` - -In this example: - -- The `forceMount` prop ensures the content is always in the DOM. -- The `child` snippet provides access to the open state and component props. -- Svelte's `#if` block controls when the content is visible. -- Transition directive ( `transition:fade` ) apply the animations. - -### Best Practices - -For cleaner code and better maintainability, consider creating custom reusable components that encapsulate this transition logic. - -MyCollapsibleContent.svelte - -```svelte - - - {#snippet child({ props, open })} - {#if open} -
- {@render children?.()} -
- {/if} - {/snippet} -
-``` - -You can then use the `MyCollapsibleContent` component alongside the other `Collapsible` primitives throughout your application: - -```svelte - - - Open - - - -``` - -## API Reference - -### Collapsible.Root - -The root collapsible container which manages the state of the collapsible. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the collapsible. The content will be visible when this is true, and hidden when it's false.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the collapsible's open state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the collapsible is disabled. This prevents the user from interacting with it.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The collapsible's open state. | -| `data-disabled` | `''` | Present when the collapsible is disabled. | -| `data-collapsible-root` | `''` | Present on the root element. | - -### Collapsible.Trigger - -The button responsible for toggling the collapsible's open state. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The collapsible's open state. | -| `data-disabled` | `''` | Present when the collapsible or this trigger is disabled. | -| `data-collapsible-trigger` | `''` | Present on the trigger element. | - -### Collapsible.Content - -The content displayed when the collapsible is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The collapsible's open state. | -| `data-disabled` | `''` | Present when the collapsible is disabled. | -| `data-collapsible-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-collapsible-content-height` | The height of the collapsible content element. | -| `--bits-collapsible-content-width` | The width of the collapsible content element. | - ----------------------------------------------------- - -# Combobox Documentation - -Enables users to pick from a list of options displayed in a dropdown. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - { - if (!o) searchValue = ""; - }} -> -
- - (searchValue = e.currentTarget.value)} - class="h-input rounded-9px border-border-input bg-background placeholder:text-foreground-alt/50 focus:ring-foreground focus:ring-offset-background focus:outline-hidden inline-flex w-[296px] truncate border px-11 text-base transition-colors focus:ring-2 focus:ring-offset-2 sm:text-sm" - placeholder="Search a fruit" - aria-label="Search a fruit" - /> - - - -
- - - - - - - {#each filteredFruits as fruit, i (i + fruit.value)} - - {#snippet children({ selected })} - {fruit.label} - {#if selected} -
- -
- {/if} - {/snippet} -
- {:else} - - No results found, try again. - - {/each} -
- - - -
-
-
-``` - -## Overview - -The Combobox component combines the functionality of an input field with a dropdown list of selectable options. It provides users with the ability to search, filter, and select from a predefined set of choices. - -## Key Features - -- **Keyboard Navigation**: Full support for keyboard interactions, allowing users to navigate and select options without using a mouse. -- **Customizable Rendering**: Flexible architecture for rendering options, including support for grouped items. -- **Accessibility**: Built with ARIA attributes and keyboard interactions to ensure screen reader compatibility and accessibility standards. -- **Portal Support**: Ability to render the dropdown content in a portal, preventing layout issues in complex UI structures. ## Architecture - -The Combobox component is composed of several sub-components, each with a specific role: - -- **Root**: The main container component that manages the state and context for the combobox. -- **Input**: The input field that allows users to enter search queries. -- **Trigger**: The button or element that opens the dropdown list. -- **Portal**: Responsible for portalling the dropdown content to the body or a custom target. -- **Group**: A container for grouped items, used to group related items. -- **GroupHeading**: A heading for a group of items, providing a descriptive label for the group. -- **Item**: An individual item within the list. -- **Separator**: A visual separator between items. -- **Content**: The dropdown container that displays the items. It uses [Floating UI](https://floating-ui.com/) - to position the content relative to the trigger. -- **ContentStatic**: An alternative to the Content component, that enables you to opt-out of Floating UI and position the content yourself. -- **Viewport**: The visible area of the dropdown content, used to determine the size and scroll behavior. -- **ScrollUpButton**: A button that scrolls the content up when the content is larger than the viewport. -- **ScrollDownButton**: A button that scrolls the content down when the content is larger than the viewport. -- **Arrow**: An arrow element that points to the trigger when using the `Combobox.Content` component. - -## Structure - -Here's an overview of how the Combobox component is structured in code: - -```svelte - - - - - - - - - - - - - - -``` - -## Reusable Components - -It's recommended to use the `Combobox` primitives to build your own custom combobox component that can be reused throughout your application. - -CustomCombobox.svelte - -```svelte - - - - - Open - - - {#each filteredItems as item, i (i + item.value)} - - {#snippet children({ selected })} - {item.label} - {selected ? "" : ""} - {/snippet} - - {:else} - No results found - {/each} - - - -``` - -+page.svelte - -```svelte - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the Combobox. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the Combobox. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Opt-out of Floating UI - -When you use the `Combobox.Content` component, Bits UI uses [Floating UI](https://floating-ui.com/) to position the content relative to the trigger, similar to other popover-like components. - -You can opt-out of this behavior by instead using the `Combobox.ContentStatic` component. - -```svelte - - - - - - - - - - - - - - - - - -``` - -When using this component, you'll need to handle the positioning of the content yourself. Keep in mind that using `Combobox.Portal` alongside `Combobox.ContentStatic` may result in some unexpected positioning behavior, feel free to not use the portal or work around it. - -## Custom Anchor - -By default, the `Combobox.Content` is anchored to the `Combobox.Input` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector string or an `HTMLElement` to the `customAnchor` prop of the `Combobox.Content` component. - -```svelte - -
- - - - - - -``` - -## What is the Viewport? - -The `Combobox.Viewport` component is used to determine the size of the content in order to determine whether or not the scroll up and down buttons should be rendered. - -If you wish to set a minimum/maximum height for the select content, you should apply it to the `Combobox.Viewport` component. - -## Scroll Up/Down Buttons - -The `Combobox.ScrollUpButton` and `Combobox.ScrollDownButton` components are used to render the scroll up and down buttons when the select content is larger than the viewport. - -You must use the `Combobox.Viewport` component when using the scroll buttons. - -### Custom Scroll Delay - -The initial and subsequent scroll delays can be controlled using the `delay` prop on the buttons. - -For example, we can use the [`cubicOut`](https://svelte.dev/docs/svelte/svelte-easing#cubicOut) easing function from Svelte to create a smooth scrolling effect that speeds up over time. - -```svelte - - { - if (!o) searchValue = ""; - }} -> -
- - (searchValue = e.currentTarget.value)} - class="h-input rounded-9px border-border-input bg-background placeholder:text-foreground-alt/50 focus:ring-foreground focus:ring-offset-background focus:outline-hidden inline-flex w-[296px] truncate border px-11 text-base transition-colors focus:ring-2 focus:ring-offset-2 sm:text-sm" - placeholder="Search a fruit" - aria-label="Search a fruit" - /> - - - -
- - - - - - - {#each filteredFruits as fruit, i (i + fruit.value)} - - {#snippet children({ selected })} - {fruit.label} - {#if selected} -
- -
- {/if} - {/snippet} -
- {:else} - - No results found, try again. - - {/each} -
- - - -
-
-
-``` - -## Native Scrolling/Overflow - -If you don't want to use the [scroll buttons](#scroll-updown-buttons) and prefer to use the standard scrollbar/overflow behavior, you can omit the `Combobox.Scroll[Up|Down]Button` components and the `Combobox.Viewport` component. - -You'll need to set a height on the `Combobox.Content` component and appropriate `overflow` styles to enable scrolling. - -## Scroll Lock - -To prevent the user from scrolling outside of the `Combobox.Content` component when open, you can set the `preventScroll` prop to `true`. - -```svelte - - -``` - -## Highlighted Items - -The Combobox component follows the [WAI-ARIA descendant pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox) for highlighting items. This means that the `Combobox.Input` retains focus the entire time, even when navigating with the keyboard, and items are highlighted as the user navigates them. - -### Styling Highlighted Items - -You can use the `data-highlighted` attribute on the `Combobox.Item` component to style the item differently when it is highlighted. - -### onHighlight / onUnhighlight - -To trigger side effects when an item is highlighted or unhighlighted, you can use the `onHighlight` and `onUnhighlight` props. - -```svelte - console.log('I am highlighted!')} onUnhighlight={() => console.log('I am unhighlighted!')} /> - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `Combobox.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
-
- {/if} - {/snippet} -
-``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - { - if (!o) searchValue = ""; - }} -> -
- - (searchValue = e.currentTarget.value)} - class="h-input rounded-9px border-border-input bg-background placeholder:text-foreground-alt/50 focus:ring-foreground focus:ring-offset-background focus:outline-hidden inline-flex w-[296px] truncate border px-11 text-base transition-colors focus:ring-2 focus:ring-offset-2 sm:text-sm" - placeholder="Search a fruit" - aria-label="Search a fruit" - /> - - - -
- - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- - - - - {#each filteredFruits as fruit, i (i + fruit.value)} - - {#snippet children({ selected })} - {fruit.label} - {#if selected} -
- -
- {/if} - {/snippet} -
- {:else} - - No results found, try again. - - {/each} -
- - - -
-
- {/if} - {/snippet} -
-
-
-``` - -## API Reference - -### Combobox.Root - -The root combobox component which manages & scopes the state of the combobox. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `enum`- 'single' \| 'multiple' | The type of combobox.`Default: undefined` | -| `value` $bindable | `union`- string \| string\[] | The value of the combobox. When the type is `'single'`, this should be a string. When the type is `'multiple'`, this should be an array of strings.`Default: undefined` | -| `onValueChange` | `function`- (string) => void \| (string\[]) => void | A callback that is fired when the combobox value changes. When the type is `'single'`, the argument will be a string. When the type is `'multiple'`, the argument will be an array of strings.`Default: undefined` | -| `open` $bindable | `boolean` | The open state of the combobox menu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the combobox menu's open state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the combobox component is disabled.`Default: false` | -| `name` | `string` | The name to apply to the hidden input element for form submission. If provided, a hidden input element will be rendered to submit the value of the combobox.`Default: undefined` | -| `required` | `boolean` | Whether or not the combobox menu is required.`Default: false` | -| `scrollAlignment` | `enum`- 'nearest' \| 'center' | The alignment of the highlighted item when scrolling.`Default: 'nearest'` | -| `loop` | `boolean` | Whether or not the combobox menu should loop through items.`Default: false` | -| `allowDeselect` | `boolean` | Whether or not the user can deselect the selected item by pressing it in a single select.`Default: true` | -| `items` | `array`- { value: string; label: string; disabled?: boolean}\[] | Optionally provide an array of objects representing the items in the select for autofill capabilities. Only applicable to combobox's with type `single``Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Combobox.Trigger - -A button which toggles the combobox's open state. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The combobox's open state. | -| `data-disabled` | `''` | Present when the combobox is disabled. | -| `data-combobox-trigger` | `''` | Present on the trigger element. | - -### Combobox.Viewport - -An optional element to track the scroll position of the combobox for rendering the scroll up/down buttons. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-combobox-viewport` | `''` | Present on the viewport element. | - -### Combobox.Content - -The element which contains the combobox's items. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not the combobox should loop through items when reaching the end.`Default: false` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The combobox's open state. | -| `data-combobox-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-combobox-content-transform-origin` | The transform origin of the combobox content element. | -| `--bits-combobox-content-available-width` | The available width of the combobox content element. | -| `--bits-combobox-content-available-height` | The available height of the combobox content element. | -| `--bits-combobox-anchor-width` | The width of the combobox trigger element. | -| `--bits-combobox-anchor-height` | The height of the combobox trigger element. | - -### Combobox.ContentStatic - -The element which contains the combobox's items. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not the combobox should loop through items when reaching the end.`Default: false` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The combobox's open state. | -| `data-combobox-content` | `''` | Present on the content element. | - -### Combobox.Portal - -When used, will render the combobox content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Combobox.Item - -A combobox item, which must be a child of the `Combobox.Content` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the item.`Default: undefined` | -| `label` | `string` | The label of the item, which is what the list will be filtered by.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the combobox item is disabled. This will prevent interaction/selection.`Default: false` | -| `onHighlight` | `function`- () => void | A callback that is fired when the item is highlighted.`Default: undefined` | -| `onUnhighlight` | `function`- () => void | A callback that is fired when the item is unhighlighted.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-value` | `string` | The value of the combobox item. | -| `data-label` | `string` | The label of the combobox item. | -| `data-disabled` | `''` | Present when the item is disabled. | -| `data-highlighted` | `''` | Present when the item is highlighted, which is either via keyboard navigation of the menu or hover. | -| `data-selected` | `''` | Present when the item is selected. | -| `data-combobox-item` | `''` | Present on the item element. | - -### Combobox.Input - -A representation of the combobox input element, which is typically displayed in the content. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `defaultValue` | `string` | The default value of the input. This is not a reactive prop and is only used to populate the input when the combobox is first mounted if there is already a value set.`Default: undefined` | -| `clearOnDeselect` | `boolean` | Whether to clear the input when the last item is deselected.`Default: false` | -| `ref` $bindable | `HTMLInputElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `enum`- 'open' \| 'closed' | The combobox's open state. | -| `data-disabled` | `''` | Present when the combobox is disabled. | -| `data-combobox-input` | `''` | Present on the input element. | - -### Combobox.Group - -A group of related combobox items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-combobox-group` | `''` | Present on the group element. | - -### Combobox.GroupHeading - -A heading for the parent combobox group. This is used to describe a group of related combobox items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-combobox-group-heading` | `''` | Present on the group heading element. | - -### Combobox.ScrollUpButton - -An optional scroll up button element to improve the scroll experience within the combobox. Should be used in conjunction with the `Combobox.Viewport` component. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `delay` | `function`- (tick: number) => number | Controls the initial delay (tick 0) and delay between auto-scrolls in milliseconds.`Default: () => 50` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-combobox-scroll-up-button` | `''` | Present on the scroll up button element. | - -### Combobox.ScrollDownButton - -An optional scroll down button element to improve the scroll experience within the combobox. Should be used in conjunction with the `Combobox.Viewport` component. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `delay` | `function`- (tick: number) => number | Controls the initial delay (tick 0) and delay between auto-scrolls in milliseconds.`Default: () => 50` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| --------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-combobox-scroll-down-button` | `''` | Present on the scroll down button element. | - -### Combobox.Arrow - -An optional arrow element which points to the content when open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-arrow` | `''` | Present on the arrow element. | - ----------------------------------------------------- - -# Command Documentation - -A command menu component that can be used to search, filter, and select items. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - - - - No results found. - - - - Suggestions - - - - - Introduction - - - - Delegation - - - - Styling - - - - - - - Components - - - - - Calendar - - - - Radio Group - - - - Combobox - - - - - - -``` - -## Overview - -The Command component, also known as a command menu, is designed to provide users with a quick and efficient way to search, filter, and select items within an application. It combines the functionality of a search input with a dynamic, filterable list of commands or options, making it ideal for applications that require fast navigation or action execution. - -## Key Features - -- **Dynamic Filtering**: As users type in the input field, the list of commands or items is instantly filtered and sorted based on an (overridable) scoring algorithm. -- **Keyboard Navigation**: Full support for keyboard interactions, allowing users to quickly navigate and select items without using a mouse. -- **Grouped Commands**: Ability to organize commands into logical groups, enhancing readability and organization. -- **Empty and Loading States**: Built-in components to handle scenarios where no results are found or when results are being loaded. -- **Accessibility**: Designed with ARIA attributes and keyboard interactions to ensure screen reader compatibility and accessibility standards. ## Architecture - -The Command component is composed of several sub-components, each with a specific role: - -- **Root**: The main container that manages the overall state and context of the command menu. -- **Input**: The text input field where users can type to search or filter commands. -- **List**: The container for the list of commands or items. -- **Viewport**: The visible area of the command list, which applies CSS variables to handle dynamic resizing/animations based on the height of the list. -- **Empty**: A component to display when no results are found. -- **Loading**: A component to display while results are being fetched or processed. -- **Group**: A container for a group of items within the command menu. -- **GroupHeading**: A header element to provide an accessible label for a group of items. -- **GroupItems**: A container for the items within a group. -- **Item**: Individual selectable command or item. -- **LinkItem**: A variant of `Command.Item` specifically for link-based actions. -- **Separator**: A visual separator to divide different sections of the command list. ## Structure - -Here's an overview of how the Command component is structured in code: - -```svelte - - - - - - - - - - - - - - - - - - - - -``` - -## Managing Value State - -Bits UI offers several approaches to manage and synchronize the Command's value state, catering to different levels of control and integration needs. - -### 1. Two-Way Binding - -For seamless state synchronization, use Svelte's `bind:value` directive. This method automatically keeps your local state in sync with the component's internal state. - -```svelte - - - - -``` - -#### Key Benefits - -- Simplifies state management -- Automatically updates `myValue` when the internal state changes (e.g., via clicking on an item) -- Allows external control (e.g., selecting an item via a separate button) ### 2. Change Handler - -To perform additional logic on state changes, use the `onValueChange` prop. This approach is useful when you need to execute side effects when the value changes. - -```svelte - - { - // do something with the new value - console.log(value); - }} -> - -``` - -#### Use Cases - -- Implementing custom behaviors on value change -- Integrating with external state management solutions -- Triggering side effects (e.g., logging, data fetching) ### 3. Fully Controlled - -For complete control over the component's state, use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) to manage the value state externally. You pass a getter function and a setter function to the `bind:value` directive, giving you full control over how the value is updated/retrieved. - -```svelte - - myValue, (newValue) => (myValue = newValue)}> - -``` - -#### When to Use - -- Implementing complex value change logic -- Coordinating multiple UI elements -- Debugging state-related issues ##### Note - -While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully. - -For more in-depth information on controlled components and advanced state management techniques, refer to our [State Management](/docs/state-management) documentation. - -## In a Modal - -You can combine the `Command` component with the `Dialog` component to display the command menu within a modal. - -```svelte - - - - - Open Command Menu J - - - - - Command Menu - - This is the command menu. Use the arrow keys to navigate and press K to - open the search bar. - - - - - - - No results found. - - - - Suggestions - - - - - Introduction - - - - Delegation - - - - Styling - - - - - - - Components - - - - - Calendar - - - - Radio Group - - - - Combobox - - - - - - - - - -``` - -## Filtering - -### Custom Filter - -By default, the `Command` component uses a scoring algorithm to determine how the items should be sorted/filtered. You can provide a custom filter function to override this behavior. - -The function should return a number between `0` and `1`, with `1` being a perfect match, and `0` being no match, resulting in the item being hidden entirely. - -The following example shows how you might implement a strict substring match filter: - -```svelte - - - -``` - -### Extend Default Filter - -By default, the `Command` component uses the `computeCommandScore` function to determine the score of each item and filters/sorts them accordingly. This function is exported for you to use and extend as needed. - -```svelte - - - -``` - -### Disable Filtering - -You can disable filtering by setting the `shouldFilter` prop to `false`. - -```svelte - - -``` - -This is useful when you have a lot of custom logic, need to fetch items asynchronously, or just want to handle filtering yourself. You'll be responsible for iterating over the items and determining which ones should be shown. - -## Item Selection - -You can use the `onSelect` prop to handle the selection of items. - -```svelte - console.log("selected something!")} /> -``` - -## Links - -If you want one of the items to get all the benefits of a link (prefetching, etc.), you should use the `Command.LinkItem` component instead of the `Command.Item` component. The only difference is that the `Command.LinkItem` component will render an `a` element instead of a `div` element. - -```svelte - - -``` - -## Imperative API - -For more advanced use cases, such as custom keybindings, the `Command.Root` component exposes several methods for programmatic control. - -Access these by binding to the component: - -```svelte - - - -``` - -### Methods - -#### `getValidItems()` Returns an array of valid (non-disabled, visible) command items. Useful for checking bounds before operations. - -```ts -const items = command.getValidItems(); -console.log(items.length); // number of selectable items -``` - -#### `updateSelectedToIndex(index: number)` Sets selection to item at specified index. No-op if index is invalid. - -```ts -// select third item (if it exists) -command.updateSelectedToIndex(2); -// with bounds check -const items = command.getValidItems(); -if (index < items.length) { - command.updateSelectedToIndex(index); -} -``` - -#### `updateSelectedByGroup(change: 1 | -1)` Moves selection to first item in next/previous group. Falls back to next/previous item if no group found. - -```ts -command.updateSelectedByGroup(1); // move to next group -command.updateSelectedByGroup(-1); // move to previous group -``` - -#### `updateSelectedByItem(change: 1 | -1)` Moves selection up/down relative to current item. Wraps around if `loop` option enabled. - -```ts -command.updateSelectedByItem(1); // next item -command.updateSelectedByItem(-1); // previous item -``` - -### Usage Example - -```svelte - - { - if (e.key === "o") { - jumpToLastItem(); - } - }} -/> - - -``` - -## Common Mistakes - -### Duplicate `value`s - -The value of each `Command.Item` ***must*** be unique. If you have two items with the same value, the component will not be able to determine which one to select, causing unexpected behavior when navigating with the keyboard or hovering with the mouse. - -If the text content of two items are the same for one reason or another, you should use the `value` prop to set a unique value for each item. When a `value` is set, the text content is used for display purposes only. The `value` prop is used for filtering and selection. - -A common pattern is to postfix the `value` with something unique, like an ID or a number so that filtering will still match the value. - -```svelte -My Item -My Item -``` - -## API Reference - -### Command.Root - -The main container that manages the overall state and context of the component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the command.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback that is fired when the command value changes.`Default: undefined` | -| `label` | `string` | An accessible label for the command menu. This is not visible and is only used for screen readers.`Default: undefined` | -| `filter` | `function`- (value: string, search: string, keywords?: string\[]) => number; | A custom filter function used to filter items. This function should return a number between `0` and `1`, with `1` being a perfect match, and `0` being no match, resulting in the item being hidden entirely. The items are sorted/filtered based on this score.`Default: undefined` | -| `shouldFilter` | `boolean` | Whether or not the command menu should filter items. This is useful when you want to apply custom filtering logic outside of the Command component.`Default: true` | -| `onStateChange` | `function`- type CommandState = { /\*\* The value of the search query \*/ search: string; /\*\* The value of the selected command menu item \*/ value: string; /\*\* The filtered items \*/ filtered: { /\*\* The count of all visible items. \*/ count: number; /\*\* Map from visible item id to its search store. \*/ items: Map\; /\*\* Set of groups with at least one visible item. \*/ groups: Set\; }; }; type onStateChange = (state: Readonly\) => void; | A callback that fires when the command's internal state changes. This callback receives a readonly snapshot of the current state. The callback is debounced and only fires once per batch of related updates (e.g., when typing triggers filtering and selection changes).`Default: undefined` | -| `loop` | `boolean` | Whether or not the command menu should loop through items when navigating with the keyboard.`Default: false` | -| `disablePointerSelection` | `boolean` | Set this to true to prevent items from being selected when the users pointer moves over them.`Default: false` | -| `vimBindings` | `boolean` | Whether VIM bindings should be enabled or not, which allow the user to navigate using ctrl+n/j/p/k`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-root` | `''` | Present on the root element. | - -### Command.Input - -The text input field where users can type to search or filter commands. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the search query. This is used to filter items and to search for items.`Default: undefined` | -| `ref` $bindable | `HTMLInputElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-command-input` | `''` | Present on the input element. | - -### Command.List - -The container for the viewport, items, and other elements of the command menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-list` | `''` | Present on the list element. | - -| CSS Variable | Description | -| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-command-list-height` | The height of the command list element, which is computed by the `Command.Viewport` component. | - -### Command.Viewport - -The visible area of the command list, which applies CSS variables to handle dynamic resizing/animations based on the height of the list. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-viewport` | `''` | Present on the viewport element. | - -### Command.Group - -A container for a group of items within the command menu. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `string` | If a `Command.GroupHeading` is used within this group, the contents of the heading will be used as the value. If the content is dynamic or you wish to have a more specific value, you can provide a unique value for the group here.`Default: undefined` | -| `forceMount` | `boolean` | Whether or not the group should always be mounted to the DOM, regardless of the internal filtering logic`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-command-group` | `''` | Present on the group element. | - -### Command.GroupHeading - -A heading element to provide an accessible label for a group of items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| --------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-group-heading` | `''` | Present on the group heading element. | - -### Command.GroupItems - -The container for the items within a group. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-command-group-items` | `''` | Present on the group items element. | - -### Command.Item - -Represents a single item within the command menu. If you wish to render an anchor element to link to a page, use the `Command.LinkItem` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the item.`Default: undefined` | -| `keywords` | `string[]` | An array of additional keywords or aliases that will be used to filter the item.`Default: undefined` | -| `forceMount` | `boolean` | Whether or not the item should always be mounted to the DOM, regardless of the internal filtering logic`Default: false` | -| `onSelect` | `function`- () => void | A callback that is fired when the item is selected.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the combobox item is disabled. This will prevent interaction/selection.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the item is disabled. | -| `data-selected` | `''` | Present when the item is selected. | -| `data-command-item` | `''` | Present on the item element. | - -### Command.LinkItem - -Similar to the `Command.Item` component, but renders an anchor element to take advantage of preloading before navigation. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the item.`Default: undefined` | -| `keywords` | `string[]` | An array of additional keywords or aliases that will be used to filter the item.`Default: undefined` | -| `forceMount` | `boolean` | Whether or not the item should always be mounted to the DOM, regardless of the internal filtering logic`Default: false` | -| `onSelect` | `function`- () => void | A callback that is fired when the item is selected.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the combobox item is disabled. This will prevent interaction/selection.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the item is disabled. | -| `data-selected` | `''` | Present when the item is selected. | -| `data-command-item` | `''` | Present on the item element. | - -### Command.Empty - -A component to display when no results are found. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the empty state, regardless of the internal filtering logic. Useful when you want to handle filtering yourself.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-command-empty` | `''` | Present on the empty element. | - -### Command.Loading - -A component to display while results are being fetched or processed. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `progress` | `number` | The progress of the loading state.`Default: 0` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| --------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-loading` | `''` | Present on the loading element. | - -### Command.Separator - -A visual separator to divide different sections of the command list. Visible when the search query is empty or the `forceMount` prop is `true`. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not the separator should always be mounted to the DOM, regardless of the internal filtering logic`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-command-separator` | `''` | Present on the separator element. | - ----------------------------------------------------- - -# Context Menu Documentation - -Displays options or actions relevant to a specific context or selected item, triggered by a right-click. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - -
- - Right click me -
-
- - - -
- - Edit -
-
- - - - - E - -
-
- - -
- - Add -
-
- - - - - N - -
-
- - - Header - - - Paragraph - - - Codeblock - - - List - - - Task - - -
- -
- - Duplicate -
-
- - - - - D - -
-
- - -
- - Delete -
-
-
-
-
-``` - -## Structure - -```svelte - - - - - - - - - - - - {#snippet children({ checked })} - {checked ? "" : ""} - {/snippet} - - - - - {#snippet children({ checked })} - {checked ? "" : ""} - {/snippet} - - - - - - - - - - - -``` - -## Reusable Components - -If you're planning to use Context Menu in multiple places, you can create a reusable component that wraps the Context Menu component. - -This example shows you how to create a Context Menu component that accepts a few custom props that make it more capable. - -CustomContextMenu.svelte - -```svelte - - - - {@render trigger()} - - - - - Select an Office - {#each items as item} - - {item} - - {/each} - - - - -``` - -You can then use the `CustomContextMenu` component like this: - -```svelte - - - {#snippet triggerArea()} -
- Right-click me -
- {/snippet} -
-``` - -Alternatively, you can define the snippet(s) separately and pass them as props to the component: - -```svelte - -{#snippet triggerArea()} -
- Right-click me -
-{/snippet} - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the menu. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Checkbox Items - -You can use the `ContextMenu.CheckboxItem` component to create a `menuitemcheckbox` element to add checkbox functionality to menu items. - -```svelte - - - {#snippet children({ checked, indeterminate })} - {#if indeterminate} - - - {:else if checked} - - {/if} - Notifications - {/snippet} - -``` - -See the [CheckboxItem API](#checkboxitem) for more information. - -## Radio Groups - -You can combine the `ContextMenu.RadioGroup` and `ContextMenu.RadioItem` components to create a radio group within a menu. - -```svelte - - - {#each values as value} - - {#snippet children({ checked })} - {#if checked} - - {/if} - {value} - {/snippet} - - {/each} - -``` - -See the [RadioGroup](#radiogroup) and [RadioItem](#radioitem) APIs for more information. - -## Nested Menus - -You can create nested menus using the `ContextMenu.Sub` component to create complex menu structures. - -```svelte - - - Item 1 - Item 2 - - Open Sub Menu - - Sub Item 1 - Sub Item 2 - - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `ContextMenu.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- Item 1 - Item 2 -
-
- {/if} - {/snippet} -
-``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - -
- - Right click me -
-
- - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- -
- - Edit -
-
- - - - - E - -
-
- - -
- - Add -
-
- - - - - N - -
-
- - - Header - - - Paragraph - - - Codeblock - - - List - - - Task - - -
- -
- - Duplicate -
-
- - - - - D - -
-
- - -
- - Delete -
-
-
-
- {/if} - {/snippet} -
-
-
-``` - -## API Reference - -### ContextMenu.Root - -The root component which manages & scopes the state of the context menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the menu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the menu's open state changes.`Default: undefined` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### ContextMenu.Trigger - -The element which when right-clicked, opens the context menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu trigger is disabled.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-trigger` | `''` | Present on the trigger element. | - -### ContextMenu.Portal - -A component that portals the content of the dropdown menu to the body or a custom target (if provided). - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### ContextMenu.Content - -The content displayed when the context menu is open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `loop` | `boolean` | Whether or not the context menu should loop through items when reaching the end.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--bits-context-menu-content-transform-origin` | The transform origin of the context menu content element. | -| `--bits-context-menu-content-available-width` | The available width of the context menu content element. | -| `--bits-context-menu-content-available-height` | The available height of the context menu content element. | -| `--bits-context-menu-anchor-width` | The width of the context menu trigger element. | -| `--bits-context-menu-anchor-height` | The height of the context menu trigger element. | - -### ContextMenu.ContentStatic - -The content displayed when the context menu is open. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `loop` | `boolean` | Whether or not the context menu should loop through items when reaching the end.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-content` | `''` | Present on the content element. | - -### ContextMenu.Item - -A menu item within the context menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu item is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-menu-item` | `''` | Present on the item element. | - -### ContextMenu.CheckboxItem - -A menu item that can be controlled and toggled like a checkbox. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the checkbox menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `checked` $bindable | `boolean` | The checkbox menu item's checked state.`Default: false` | -| `onCheckedChange` | `function`- (checked: boolean) => void | A callback that is fired when the checkbox menu item's checked state changes.`Default: undefined` | -| `indeterminate` $bindable | `boolean` | Whether the checkbox menu item is in an indeterminate state or not.`Default: false` | -| `onIndeterminateChange` | `function`- (indeterminate: boolean) => void | A callback that is fired when the indeterminate state changes.`Default: undefined` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The checkbox menu item's checked state. | ### ContextMenu.RadioGroup - -A group of radio menu items, where only one can be checked at a time. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently checked radio menu item.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback that is fired when the radio group's value changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-radio-group` | `''` | Present on the radio group element. | - -### ContextMenu.RadioItem - -A menu item that can be controlled and toggled like a radio button. It must be a child of a `RadioGroup`. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the radio item. When checked, the parent `RadioGroup`'s value will be set to this value.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the radio menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The radio menu item's checked state. | -| `data-value` | `''` | The value of the radio item. | -| `data-menu-radio-item` | `''` | Present on the radio item element. | - -### ContextMenu.Separator - -A horizontal line to visually separate menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | The orientation of the separator. | -| `data-menu-separator` | `''` | Present on the separator element. | - -### ContextMenu.Arrow - -An optional arrow which points to the context menu's anchor/trigger point. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-arrow` | `''` | Present on the arrow element. | - -### ContextMenu.Group - -A group of menu items. It should be passed an `aria-label` or have a child `ContextMenu.GroupHeading` component to provide a label for a group of menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-group` | `''` | Present on the group element. | - -### ContextMenu.GroupHeading - -A heading for a group which will be skipped when navigating with the keyboard. It is used to provide a visual label for a group of menu items and must be a child of either a `ContextMenu.Group` or `ContextMenu.RadioGroup` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-menu-group-heading` | `''` | Present on the group heading element. | - -### ContextMenu.Sub - -A submenu belonging to the parent context menu. Responsible for managing the state of the submenu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the submenu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the submenu's open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### ContextMenu.SubTrigger - -A menu item which when pressed or hovered, opens the submenu it is a child of. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the submenu trigger is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-trigger` | `''` | Present on the submenu trigger element. | - -### ContextMenu.SubContent - -The submenu content displayed when the parent submenu is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - -### ContextMenu.SubContentStatic - -The submenu content displayed when the parent submenu menu is open. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items when reaching the end of the list when using the keyboard.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - ----------------------------------------------------- - -# Date Field Documentation - -Enables users to input specific dates within a designated field. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
- Birthday - - {#snippet children({ segments })} - {#each segments as { part, value }} -
- {#if part === "literal"} - - {value} - - {:else} - - {value} - - {/if} -
- {/each} - {/snippet} -
-
-
-``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Overview - -The `DateField` component is an alternative to the native `` element. It provides a more flexible and customizable way to select dates within a designated field. - -## Structure - -```svelte - - - Check-in date - - {#snippet children({ segments })} - {#each segments as { part, value }} - - {value} - - {/each} - {/snippet} - - -``` - -## Reusable Components - -It's recommended to use the `DateField` primitives to build your own custom date field component that can be used throughout your application. - -The following example shows how you might create a reusable `MyDateField` component that can be used throughout your application. For style inspiration, reference the featured demo at the top of this page. - -MyDateField.svelte - -```svelte - - - {labelText} - - {#snippet children({ segments })} - {#each segments as { part, value }} - - {value} - - {/each} - {/snippet} - - -``` - -```svelte - - -
- {labelText} - - {#snippet children({ segments })} - {#each segments as { part, value }} -
- {#if part === "literal"} - - {value} - - {:else} - - {value} - - {/if} -
- {/each} - {/snippet} -
-
-
-``` - -We'll be using this newly created `MyDateField` component in the following demos and examples to prevent repeating the same code, so be sure to reference it as you go through the documentation. - -## Segments - -A segment of the `DateField` represents a not only a specific part of the date, such as the day, month, year, hour, but can also reference a `"literal"` which is typically a separator between the different parts of the date, and varies based on the `locale`. - -Notice that in the `MyDateField` component we created, we're styling the `DateField.Segment` components differently based on whether they are a `"literal"` or not. - -## Placeholder - -The `placeholder` prop for the `DateField.Root` component isn't what is displayed when the field is empty, but rather what date our field should start with when the user attempts to cycle through the segments. The placeholder can also be used to set a granularity for the date field, which will determine which type of `DateValue` object is used for the `value`. - -By default, the `placeholder` will be set to the current date, and be of type `CalendarDate`. However, if we wanted this date field to also allow for selecting a time, we could set the placeholder to a `CalendarDateTime` object. - -```svelte - - -``` - -If we're collecting a date from the user where we want the timezone as well, we can use a `ZonedDateTime` object instead. - -```svelte - - -``` - -##### Leap Years! - -If you're creating a date field for something like a birthday, ensure your placeholder is set in a leap year to ensure users born on a leap year will be able to select the correct date. - -## Managing Placeholder State - -This section covers how to manage the `placeholder` state of the Date Field. - -### Two-Way Binding - -Use `bind:placeholder` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the Date Field. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -For complete control over the component's state, use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) to manage the value state externally. - -```svelte - - - -``` - -## Default Value - -Often, you'll want to start the `DateField.Root` component with a default value. Likely this value will come from a database in the format of an ISO 8601 string. You can use the `parseDate` function from the `@internationalized/date` package to parse the string into a `CalendarDate` object. - -+page.svelte - -```svelte - - - -``` - -Now our input is populated with the default value. In addition to the `parseDate` function, you can also use `parseDateTime` or `parseZonedDateTime` to parse the string into a `CalendarDateTime` or `ZonedDateTime` object respectively. - -## Validation - -### Minimum Value - -You can set a minimum value for the `DateField.Root` component by using the `minValue` prop. If a user selects a date that is less than the minimum value, the date field will be marked as invalid. - -```svelte - - -``` - -In the example above, we're setting the minimum value to today, and the default value to yesterday. This causes the date field to be marked as invalid, and we can style it accordingly. If you adjust the date to be greater than the minimum value, the invalid state will be cleared. - -### Maximum Value - -You can set a maximum value for the `DateField.Root` component by using the `maxValue` prop. If a user selects a date that is greater than the maximum value, the date field will be marked as invalid. - -```svelte - - -``` - -In the example above, we're setting the maximum value to today, and the default value to tomorrow. This causes the date field to be marked as invalid, and we can style it accordingly. If you adjust the date to be less than the maximum value, the invalid state will be cleared. - -### Custom Validation - -You can use the `validate` prop to provide a custom validation function for the date field. This function should return a string or array of strings as validation errors if the date is invalid, or undefined/nothing if the date is valid. - -The strings are then passed to the `onInvalid` callback, which you can use to display an error message to the user. - -```svelte - - -``` - -In the example above, we're setting the `isDateUnavailable` prop to a function that returns `true` for the first day of the month. Try selecting a date that is the first day of the month to see the date field marked as invalid. - -## Granularity - -The `granularity` prop sets the granularity of the date field, which determines which segments are rendered in the date field. The `granularity` can be set to either `'day'`, `'hour'`, `'minute'`, or `'second'`. - -```svelte - - -``` - -In the example above, we're setting the granularity to `'second'`, which means that the date field will include an additional segment for the seconds. - -## Localization - -You can use the `locale` prop to set the locale of the date field. This will affect the formatting of the date field's segments and placeholders. - -```svelte - - -``` - -## API Reference - -### DateField.Root - -The root date field component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The selected date.`Default: undefined` | -| `onValueChange` | `function`- (value: DateValue) => void | A function that is called when the selected date changes.`Default: undefined` | -| `placeholder` $bindable | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what date to start the segments from when no value exists.`Default: undefined` | -| `onPlaceholderChange` | `function`- (value: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `required` | `boolean` | Whether or not the date field is required.`Default: false` | -| `validate` | `function`- (value: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `onInvalid` | `function`- (value: DateValue) => void | A callback fired when the date field's value is invalid.`Default: undefined` | -| `errorMessageId` | `string` | The `id` of the element which contains the error messages for the date field when the date is invalid.`Default: undefined` | -| `hourCycle` | `enum`- '12' \| '24' | The hour cycle to use for formatting times. Defaults to the locale preference`Default: undefined` | -| `granularity` | `enum`- 'day' \| 'hour' \| 'minute' \| 'second' | The granularity to use for formatting the field. Defaults to `'day'` if a `CalendarDate` is provided, otherwise defaults to `'minute'`. The field will render segments for each part of the date up to and including the specified granularity.`Default: undefined` | -| `hideTimeZone` | `boolean` | Whether or not to hide the time zone segment of the field.`Default: false` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum valid date that can be entered.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum valid date that can be entered.`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en-US'` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the field is readonly.`Default: false` | -| `readonlySegments` | `EditableSegmentPart[]`- "day" \| "month" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" | An array of segments that should be readonly, which prevent user input on them.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DateField.Input - -The container for the segments of the date field. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | `string` | The name of the date field used for form submission. If provided, a hidden input element will be rendered alongside the date field.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid. | -| `data-disabled` | `''` | Present on the element when the field is disabled. | -| `data-date-field-input` | `''` | Present on the element. | - -### DateField.Segment - -A segment of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `part` required | `SegmentPart`- "month" \| "day" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" \| "timeZoneName" \| "literal"; | The part of the date to render.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-readonly` | `''` | Present on the element when the field or segment is readonly | -| `data-segment` | `enum` | The part of the date to render. | -| `data-date-field-segment` | `''` | Present on the element. | - -### DateField.Label - -The label for the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-date-field-label` | `''` | Present on the element. | - ----------------------------------------------------- - -# Date Picker Documentation - -Facilitates the selection of dates through an input and calendar-based interface. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
- Birthday - - {#snippet children({ segments })} - {#each segments as { part, value }} -
- {#if part === "literal"} - - {value} - - {:else} - - {value} - - {/if} -
- {/each} - - - - {/snippet} -
- - - {#snippet children({ months, weekdays })} - - - - - - - - - -
- {#each months as month} - - - - {#each weekdays as day} - -
{day.slice(0, 2)}
-
- {/each} -
-
- - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
- {/each} -
- {/snippet} -
-
-
-
-``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Structure - -```svelte - - - - - {#snippet children({ segments })} - {#each segments as { part, value }} - - {value} - - {/each} - - {/snippet} - - - - {#snippet children({ months, weekdays })} - - - - - - {#each months as month} - - - - {#each weekdays as day} - - {day} - - {/each} - - - - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {/each} - - {/each} - - - {/each} - {/snippet} - - - -``` - -## Managing Placeholder State - -This section covers how to manage the `placeholder` state of the component. - -### Two-Way Binding - -Use `bind:placeholder` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Customization - -The `DatePicker` component is made up of three other Bits UI components: [Date Field](/docs/components/date-field), [Calendar](/docs/components/calendar), and [Popover](/docs/components/popover). - -You can check out the documentation for each of these components to learn more about their customization options, each of which can be used to customize the `DatePicker` component. - -## API Reference - -### DatePicker.Root - -The root date picker component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `union`- DateValue \| DateValue\[] | The selected date(s). If `type` is `'single'`, this will be a `DateValue`. If `type` is `'multiple'`, this will be an array of `DateValue`s.`Default: undefined` | -| `onValueChange` | `function`- (value: DateValue) => void \| (value: DateValue\[]) => void | A function that is called when the selected date changes.`Default: undefined` | -| `open` $bindable | `boolean` | The open state of the popover content.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that fires when the open state changes.`Default: undefined` | -| `placeholder` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what month to display when no date is selected. This updates as the user navigates the calendar, and can be used to programmatically control the calendar's view.`Default: undefined` | -| `onPlaceholderChange` | `function`- (date: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `isDateUnavailable` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `isDateDisabled` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is disabled.`Default: undefined` | -| `validate` | `function`- (value: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `onInvalid` | `function`- (value: DateValue) => void | A callback fired when the date field's value is invalid.`Default: undefined` | -| `required` | `boolean` | Whether or not the date field is required.`Default: false` | -| `errorMessageId` | `string` | The `id` of the element which contains the error messages for the date field when the date is invalid.`Default: undefined` | -| `readonlySegments` | `EditableSegmentPart[]`- "day" \| "month" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" | An array of segments that should be readonly, which prevent user input on them.`Default: undefined` | -| `disableDaysOutsideMonth` | `boolean` | Whether or not to disable days outside the current month.`Default: false` | -| `closeOnDateSelect` | `boolean` | Whether or not to close the popover when a date is selected.`Default: true` | -| `pagedNavigation` | `boolean` | Whether or not to use paged navigation for the calendar. Paged navigation causes the previous and next buttons to navigate by the number of months displayed at once, rather than by one month.`Default: false` | -| `preventDeselect` | `boolean` | Whether or not to prevent the user from deselecting a date without selecting another date first.`Default: false` | -| `weekStartsOn` | `number` | The day of the week to start the calendar on. 0 is Sunday, 1 is Monday, etc.`Default: 0` | -| `weekdayFormat` | `enum`- 'narrow' \| 'short' \| 'long' | The format to use for the weekday strings provided via the `weekdays` slot prop.`Default: 'narrow'` | -| `calendarLabel` | `string` | The accessible label for the calendar.`Default: undefined` | -| `fixedWeeks` | `boolean` | Whether or not to always display 6 weeks in the calendar.`Default: false` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum date that can be selected.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum date that can be selected.`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en'` | -| `numberOfMonths` | `number` | The number of months to display at once.`Default: 1` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the field is readonly.`Default: false` | -| `hourCycle` | `enum`- '12' \| '24' | The hour cycle to use for formatting times. Defaults to the locale preference`Default: undefined` | -| `granularity` | `enum`- 'day' \| 'hour' \| 'minute' \| 'second' | The granularity to use for formatting the field. Defaults to `'day'` if a `CalendarDate` is provided, otherwise defaults to `'minute'`. The field will render segments for each part of the date up to and including the specified granularity.`Default: undefined` | -| `hideTimeZone` | `boolean` | Whether or not to hide the time zone segment of the field.`Default: false` | -| `initialFocus` | `boolean` | If `true`, the calendar will focus the selected day, today, or the first day of the month in that order depending on what is visible when the calendar is mounted.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -| Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the root element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the root element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the root element when the calendar is readonly. | -| `data-date-picker-root` | `''` | Present on the root element. | - -### DatePicker.Label - -The label for the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-date-field-label` | `''` | Present on the element. | - -### DatePicker.Input - -The field input component which contains the segments of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | -| `name` | `string` | The name of the date field used for form submission. If provided, a hidden input element will be rendered alongside the date field.`Default: undefined` | - -| Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid. | -| `data-disabled` | `''` | Present on the element when the field is disabled. | -| `data-date-field-input` | `''` | Present on the element. | - -### DatePicker.Segment - -A segment of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `part` required | `SegmentPart`- "month" \| "day" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" \| "timeZoneName" \| "literal"; | The part of the date to render.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-readonly` | `''` | Present on the element when the field or segment is readonly | -| `data-segment` | `enum` | The part of the date to render. | -| `data-date-field-segment` | `''` | Present on the element. | - -### DatePicker.Trigger - -A component which toggles the opening and closing of the popover on press. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-trigger` | `''` | Present on the trigger element. | - -### DatePicker.Content - -The contents of the popover which are displayed when the popover is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-popover-content-transform-origin` | The transform origin of the popover content element. | -| `--bits-popover-content-available-width` | The available width of the popover content element. | -| `--bits-popover-content-available-height` | The available height of the popover content element. | -| `--bits-popover-anchor-width` | The width of the popover trigger element. | -| `--bits-popover-anchor-height` | The height of the popover trigger element. | - -### DatePicker.Portal - -When used, will render the popover content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DatePicker.Calendar - -The calendar component containing the grids of dates. - -| Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the calendar element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the calendar element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the calendar element when the calendar is readonly. | -| `data-calendar-root` | `''` | Present on the calendar element. | - -### DatePicker.Header - -The header of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the header element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the header element when the calendar is readonly. | -| `data-calendar-header` | `''` | Present on the header element. | - -### DatePicker.PrevButton - -The previous button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the prev button element when the calendar or this button is disabled. | -| `data-calendar-prev-button` | `''` | Present on the prev button element. | - -### DatePicker.Heading - -The heading of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the heading element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the heading element when the calendar is readonly. | -| `data-calendar-heading` | `''` | Present on the heading element. | - -### DatePicker.NextButton - -The next button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the next button element when the calendar or this button is disabled. | -| `data-calendar-next-button` | `''` | Present on the next button element. | - -### DatePicker.Grid - -The grid of dates in the calendar, typically representing a month. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid` | `''` | Present on the grid element. | - -### DatePicker.GridRow - -A row in the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableRowElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid row element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid row element when the calendar is readonly. | -| `data-calendar-grid-row` | `''` | Present on the grid row element. | - -### DatePicker.GridHead - -The head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid head element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid head element when the calendar is readonly. | -| `data-calendar-grid-head` | `''` | Present on the grid head element. | - -### DatePicker.HeadCell - -A cell in the head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the head cell element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the head cell element when the calendar is readonly. | -| `data-calendar-head-cell` | `''` | Present on the head cell element. | - -### DatePicker.GridBody - -The body of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid-body` | `''` | Present on the grid body element. | - -### DatePicker.Cell - -A cell in the calendar grid. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `date` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The date for the cell.`Default: undefined` | -| `month` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The current month the date is being displayed in.`Default: undefined` | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-cell` | `''` | Present on the cell element. | - -### DatePicker.Day - -A day in the calendar grid. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-day` | `''` | Present on the day element. | - ----------------------------------------------------- - -# Date Range Field Documentation - -Allows users to input a range of dates within a designated field. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - Hotel dates - -
- {#each ["start", "end"] as const as type} - - {#snippet children({ segments })} - {#each segments as { part, value }} -
- {#if part === "literal"} - - {value} - - {:else} - - {value} - - {/if} -
- {/each} - {/snippet} -
- {#if type === "start"} - - {/if} - {/each} -
-
-``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Overview - -The `DateRangeField` component combines two [Date Field](/docs/components/date-field) components to create a date range field. Check out the [Date Field](/docs/components/date-field) component documentation for information on how to customize this component. - -## Structure - -```svelte - - - Check-in date - {#each ["start", "end"] as const as type} - - {#snippet children({ segments })} - {#each segments as { part, value }} - - {value} - - {/each} - {/snippet} - - {/each} - -``` - -## Managing Placeholder State - -This section covers how to manage the `placeholder` state of the component. - -### Two-Way Binding - -Use `bind:placeholder` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## API Reference - -### DateRangeField.Root - -The root date field component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `DateRange`- { start: DateValue; end: DateValue } | The selected date range.`Default: undefined` | -| `onValueChange` | `function`- (value: DateRange) => void | A function that is called when the selected date changes.`Default: undefined` | -| `placeholder` $bindable | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what date to start the segments from when no value exists.`Default: undefined` | -| `onPlaceholderChange` | `function`- (value: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `errorMessageId` | `string` | The `id` of the element which contains the error messages for the date field when the date is invalid.`Default: undefined` | -| `validate` | `function`- (value: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `onInvalid` | `function`- (value: DateValue) => void | A callback fired when the date field's value is invalid.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum valid date that can be entered.`Default: undefined` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum valid date that can be entered.`Default: undefined` | -| `granularity` | `enum`- 'day' \| 'hour' \| 'minute' \| 'second' | The granularity to use for formatting the field. Defaults to `'day'` if a `CalendarDate` is provided, otherwise defaults to `'minute'`. The field will render segments for each part of the date up to and including the specified granularity.`Default: undefined` | -| `hideTimeZone` | `boolean` | Whether or not to hide the time zone segment of the field.`Default: false` | -| `hourCycle` | `enum`- '12' \| '24' | The hour cycle to use for formatting times. Defaults to the locale preference`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en-US'` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the field is readonly.`Default: false` | -| `readonlySegments` | `EditableSegmentPart[]`- "day" \| "month" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" | An array of segments that should be readonly, which prevent user input on them.`Default: undefined` | -| `required` | `boolean` | Whether or not the date field is required.`Default: false` | -| `onStartValueChange` | `function`- (value: DateValue) => void | A function that is called when the start date changes.`Default: undefined` | -| `onEndValueChange` | `function`- (value: DateValue) => void | A function that is called when the end date changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| --------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-date-range-field-root` | `''` | Present on the root element. | - -### DateRangeField.Input - -The container for the segments of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `enum`- 'start' \| 'end' | The type of field to render (start or end).`Default: undefined` | -| `name` | `string` | The name of the date field used for form submission. If provided, a hidden input element will be rendered alongside the date field.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid. | -| `data-disabled` | `''` | Present on the element when the field is disabled. | -| `data-date-field-input` | `''` | Present on the element. | - -### DateRangeField.Segment - -A segment of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `part` required | `SegmentPart`- "month" \| "day" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" \| "timeZoneName" \| "literal" | The part of the date to render.`Default: undefined` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-segment` | `enum`- '' | The type of segment the element represents. | -| `data-date-field-segment` | `''` | Present on the element. | - -### DateRangeField.Label - -The label for the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-date-field-label` | `''` | Present on the element. | - ----------------------------------------------------- - -# Date Range Picker Documentation - -Facilitates the selection of date ranges through an input and calendar-based interface. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - Rental Days -
- {#each ["start", "end"] as const as type} - - {#snippet children({ segments })} - {#each segments as { part, value }} -
- {#if part === "literal"} - - {value} - - {:else} - - {value} - - {/if} -
- {/each} - {/snippet} -
- {#if type === "start"} - - {/if} - {/each} - - - -
- - - {#snippet children({ months, weekdays })} - - - - - - - - - -
- {#each months as month} - - - - {#each weekdays as day} - -
{day.slice(0, 2)}
-
- {/each} -
-
- - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
- {/each} -
- {/snippet} -
-
-
-``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Structure - -```svelte - - - - {#each ["start", "end"] as const as type} - - {#snippet children({ segments })} - {#each segments as { part, value }} - - {value} - - {/each} - {/snippet} - - {/each} - - - - {#snippet children({ months, weekdays })} - - - - - - {#each months as month} - - - - {#each weekdays as day} - - {day} - - {/each} - - - - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - {date.day} - - - {/each} - - {/each} - - - {/each} - {/snippet} - - - -``` - -## Managing Placeholder State - -This section covers how to manage the `placeholder` state of the component. - -### Two-Way Binding - -Use `bind:placeholder` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Customization - -The `DateRangePicker` component is made up of three other Bits UI components: [Date Range Field](/docs/components/date-range-field), [Range Calendar](/docs/components/range-calendar), and [Popover](/docs/components/popover). - -You can check out the documentation for each of these components to learn more about their customization options, each of which can be used to customize the `DateRangePicker` component. - -## API Reference - -### DateRangePicker.Root - -The root date picker component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `DateRange`- { start: DateValue; end: DateValue } | The selected date range.`Default: undefined` | -| `onValueChange` | `function`- (value: DateRange) => void | A function that is called when the selected date changes.`Default: undefined` | -| `placeholder` $bindable | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what date to start the segments from when no value exists.`Default: undefined` | -| `onPlaceholderChange` | `function`- (value: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `readonlySegments` | `EditableSegmentPart[]`- "day" \| "month" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" | An array of segments that should be readonly, which prevent user input on them.`Default: undefined` | -| `isDateUnavailable` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum valid date that can be entered.`Default: undefined` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum valid date that can be entered.`Default: undefined` | -| `validate` | `function`- (value: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `onInvalid` | `function`- (value: DateValue) => void | A callback fired when the date field's value is invalid.`Default: undefined` | -| `granularity` | `enum`- 'day' \| 'hour' \| 'minute' \| 'second' | The granularity to use for formatting the field. Defaults to `'day'` if a `CalendarDate` is provided, otherwise defaults to `'minute'`. The field will render segments for each part of the date up to and including the specified granularity.`Default: undefined` | -| `hideTimeZone` | `boolean` | Whether or not to hide the time zone segment of the field.`Default: false` | -| `errorMessageId` | `string` | The `id` of the element which contains the error messages for the date field when the date is invalid.`Default: undefined` | -| `hourCycle` | `enum`- '12' \| '24' | The hour cycle to use for formatting times. Defaults to the locale preference`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en-US'` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the field is readonly.`Default: false` | -| `required` | `boolean` | Whether or not the date field is required.`Default: false` | -| `closeOnRangeSelect` | `boolean` | Whether or not to close the popover when a date range is selected.`Default: true` | -| `disableDaysOutsideMonth` | `boolean` | Whether or not to disable days outside the current month.`Default: false` | -| `pagedNavigation` | `boolean` | Whether or not to use paged navigation for the calendar. Paged navigation causes the previous and next buttons to navigate by the number of months displayed at once, rather than by one month.`Default: false` | -| `preventDeselect` | `boolean` | Whether or not to prevent the user from deselecting a date without selecting another date first.`Default: false` | -| `weekdayFormat` | `enum`- 'narrow' \| 'short' \| 'long' | The format to use for the weekday strings provided via the `weekdays` slot prop.`Default: 'narrow'` | -| `weekStartsOn` | `number` | The day of the week to start the calendar on. 0 is Sunday, 1 is Monday, etc.`Default: 0` | -| `calendarLabel` | `string` | The accessible label for the calendar.`Default: undefined` | -| `fixedWeeks` | `boolean` | Whether or not to always display 6 weeks in the calendar.`Default: false` | -| `isDateDisabled` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is disabled.`Default: undefined` | -| `numberOfMonths` | `number` | The number of months to display at once.`Default: 1` | -| `open` $bindable | `boolean` | The open state of the popover content.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that fires when the open state changes.`Default: undefined` | -| `onEndValueChange` | `function`- (value: DateValue) => void | A function that is called when the end date changes.`Default: undefined` | -| `onStartValueChange` | `function`- (value: DateValue) => void | A function that is called when the start date changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the root element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the root element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the root element when the calendar is readonly. | -| `data-calendar-root` | `''` | Present on the root element. | - -### DateRangePicker.Label - -The label for the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-date-field-label` | `''` | Present on the element. | - -### DateRangePicker.Input - -The field input component which contains the segments of the date field. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | `string` | The name of the input field used for form submission. If provided a hidden input will be rendered alongside the field.`Default: undefined` | -| `type` required | `enum`- 'start' \| 'end' | The type of field to render (start or end).`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid. | -| `data-disabled` | `''` | Present on the element when the field is disabled. | -| `data-date-field-input` | `''` | Present on the element. | - -### DateRangePicker.Segment - -A segment of the date field. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `part` required | `SegmentPart`- "month" \| "day" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" \| "timeZoneName" \| "literal" | The part of the date to render.`Default: undefined` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `data-invalid` | `''` | Present on the element when the field is invalid | -| `data-disabled` | `''` | Present on the element when the field is disabled | -| `data-segment` | `enum`- '' | The type of segment the element represents. | -| `data-date-field-segment` | `''` | Present on the element. | - -### DateRangePicker.Trigger - -A component which toggles the opening and closing of the popover on press. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-trigger` | `''` | Present on the trigger element. | - -### DateRangePicker.Content - -The contents of the popover which are displayed when the popover is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-popover-content-transform-origin` | The transform origin of the popover content element. | -| `--bits-popover-content-available-width` | The available width of the popover content element. | -| `--bits-popover-content-available-height` | The available height of the popover content element. | -| `--bits-popover-anchor-width` | The width of the popover trigger element. | -| `--bits-popover-anchor-height` | The height of the popover trigger element. | - -### DateRangePicker.Portal - -When used, will render the popover content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DateRangePicker.Calendar - -The calendar component containing the grids of dates. - -| Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the root element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the root element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the root element when the calendar is readonly. | -| `data-calendar-root` | `''` | Present on the root element. | - -### DateRangePicker.Header - -The header of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the header element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the header element when the calendar is readonly. | -| `data-calendar-header` | `''` | Present on the header element. | - -### DateRangePicker.PrevButton - -The previous button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the prev button element when the calendar or this button is disabled. | -| `data-calendar-prev-button` | `''` | Present on the prev button element. | - -### DateRangePicker.Heading - -The heading of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the heading element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the heading element when the calendar is readonly. | -| `data-calendar-heading` | `''` | Present on the heading element. | - -### DateRangePicker.NextButton - -The next button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the next button element when the calendar or this button is disabled. | -| `data-calendar-next-button` | `''` | Present on the next button element. | - -### DateRangePicker.Grid - -The grid of dates in the calendar, typically representing a month. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid` | `''` | Present on the grid element. | - -### DateRangePicker.GridRow - -A row in the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableRowElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid row element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid row element when the calendar is readonly. | -| `data-calendar-grid-row` | `''` | Present on the grid row element. | - -### DateRangePicker.GridHead - -The head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid head element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid head element when the calendar is readonly. | -| `data-calendar-grid-head` | `''` | Present on the grid head element. | - -### DateRangePicker.HeadCell - -A cell in the head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the head cell element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the head cell element when the calendar is readonly. | -| `data-calendar-head-cell` | `''` | Present on the head cell element. | - -### DateRangePicker.GridBody - -The body of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid-body` | `''` | Present on the grid body element. | - -### DateRangePicker.Cell - -A cell in the calendar grid. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `date` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The date for the cell.`Default: undefined` | -| `month` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The current month the date is being displayed in.`Default: undefined` | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-cell` | `''` | Present on the cell element. | - -### DateRangePicker.Day - -A day in the calendar grid. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-day` | `''` | Present on the day element. | - ----------------------------------------------------- - -# Dialog Documentation - -A modal window presenting content or seeking user input without navigating away from the current context. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - New API key - - - - - - Create API key - - - - Create and manage API keys. You can create multiple keys to organize - your applications. - -
- API Key -
- - -
-
-
- - Save - -
- -
- - Close -
-
-
-
-
-``` - -## Overview - -The Dialog component in Bits UI provides a flexible and accessible way to create modal dialogs in your Svelte applications. It follows a compound component pattern, allowing for fine-grained control over the dialog's structure and behavior while maintaining accessibility and ease of use. - -## Key Features - -- **Compound Component Structure**: Offers a set of sub-components that work together to create a fully-featured dialog. -- **Accessibility**: Built with WAI-ARIA guidelines in mind, ensuring keyboard navigation and screen reader support. -- **Customizable**: Each sub-component can be styled and configured independently. -- **Portal Support**: Content can be rendered in a portal, ensuring proper stacking context. -- **Managed Focus**: Automatically manages focus, with the option to take control if needed. -- **Flexible State Management**: Supports both controlled and uncontrolled state, allowing for full control over the dialog's open state. ## Architecture - -The Dialog component is composed of several sub-components, each with a specific role: - -- **Root**: The main container component that manages the state of the dialog. Provides context for all child components. -- **Trigger**: A button that toggles the dialog's open state. -- **Portal**: Renders its children in a portal, outside the normal DOM hierarchy. -- **Overlay**: A backdrop that sits behind the dialog content. -- **Content**: The main container for the dialog's content. -- **Title**: Renders the dialog's title. -- **Description**: Renders a description or additional context for the dialog. -- **Close**: A button that closes the dialog. ## Structure - -Here's an overview of how the Dialog component is structured in code: - -```svelte - - - - - - - - - - - - -``` - -## Reusable Components - -Bits UI provides a comprehensive set of Dialog components that serve as building blocks for creating customized, reusable Dialog implementations. This approach offers flexibility in design while maintaining consistency and accessibility across your application. - -### Building a Reusable Dialog - -The following example demonstrates how to create a versatile, reusable Dialog component using Bits UI building blocks. This implementation showcases the flexibility of the component API by combining props and snippets. - -MyDialog.svelte - -```svelte - - - - {buttonText} - - - - - - {@render title()} - - - {@render description()} - - {@render children?.()} - Close Dialog - - - -``` - -#### Usage with Inline Snippets - -```svelte - - - {#snippet title()} - Account settings - {/snippet} - {#snippet description()} - Manage your account settings and preferences. - {/snippet} - -``` - -#### Usage with Separate Snippets - -```svelte - -{#snippet title()} - Account settings -{/snippet} -{#snippet description()} - Manage your account settings and preferences. -{/snippet} - - -``` - -### Best Practices - -- **Prop Flexibility**: Design your component to accept props for any nested components for maximum flexibility -- **Styling Options**: Use tools like `clsx` to merge class overrides -- **Binding Props**: Use `bind:` and expose `$bindable` props to provide consumers with full control -- **Type Safety**: Use the exported types from Bits UI to type your component props ## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Focus Management - -Proper focus management is crucial for accessibility and user experience in modal dialogs. Bits UI's Dialog component provides several features to help you manage focus effectively. - -### Focus Trap - -By default, the Dialog implements a focus trap, adhering to the WAI-ARIA design pattern for modal dialogs. This ensures that keyboard focus remains within the Dialog while it's open, preventing users from interacting with the rest of the page. - -#### Disabling the Focus Trap - -While not recommended, you can disable the focus trap if absolutely necessary: - -```svelte - - -``` - -##### Accessibility Warning - -Disabling the focus trap may compromise accessibility. Only do this if you have a specific reason and implement an alternative focus management strategy. - -### Open Focus - -When a Dialog opens, focus is automatically set to the first focusable element within `Dialog.Content`. This ensures keyboard users can immediately interact with the Dialog contents. - -#### Customizing Initial Focus - -To specify which element receives focus when the Dialog opens, use the `onOpenAutoFocus` prop on `Dialog.Content`: - -```svelte - - - Open Dialog - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - - -``` - -##### Important - -Always ensure that *something* within the Dialog receives focus when it opens. This is crucial for maintaining keyboard navigation context and makes your users happy. - -### Close Focus - -When a Dialog closes, focus returns to the element that triggered its opening (typically the `Dialog.Trigger`). - -#### Customizing Close Focus - -To change which element receives focus when the Dialog closes, use the `onCloseAutoFocus` prop on `Dialog.Content`: - -```svelte - - - - Open Dialog - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - -``` - -### Best Practices - -- Always maintain a clear focus management strategy for your Dialogs. -- Ensure that focus is predictable and logical for keyboard users. -- Test your focus management with keyboard navigation to verify its effectiveness. ## Advanced Behaviors - -Bits UI's Dialog component offers several advanced features to customize its behavior and enhance user experience. This section covers scroll locking, escape key handling, and interaction outside the dialog. - -### Scroll Lock - -By default, when a Dialog opens, scrolling the body is disabled. This provides a more native-like experience, focusing user attention on the dialog content. - -#### Customizing Scroll Behavior - -To allow body scrolling while the dialog is open, use the `preventScroll` prop on `Dialog.Content`: - -```svelte - - -``` - -##### Note - -Enabling body scroll may affect user focus and accessibility. Use this option judiciously. - -### Escape Key Handling - -By default, pressing the `Escape` key closes an open Dialog. Bits UI provides two methods to customize this behavior. - -#### Method 1: `escapeKeydownBehavior` The `escapeKeydownBehavior` prop allows you to customize the behavior taken by the component when the `Escape` key is pressed. It accepts one of the following values: - -- `'close'` (default): Closes the Dialog immediately. -- `'ignore'` : Prevents the Dialog from closing. -- `'defer-otherwise-close'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Dialog will close immediately. -- `'defer-otherwise-ignore'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Dialog will ignore the key press and not close. - -To always prevent the Dialog from closing on Escape key press, set the `escapeKeydownBehavior` prop to `'ignore'` on `Dialog.Content`: - -```svelte - - -``` - -#### Method 2: `onEscapeKeydown` For more granular control, override the default behavior using the `onEscapeKeydown` prop: - -```svelte - { - e.preventDefault(); - // do something else instead - }} -> - -``` - -This method allows you to implement custom logic when the `Escape` key is pressed. - -### Interaction Outside - -By default, interacting outside the Dialog content area closes the Dialog. Bits UI offers two ways to modify this behavior. - -#### Method 1: `interactOutsideBehavior` The `interactOutsideBehavior` prop allows you to customize the behavior taken by the component when an interaction (touch, mouse, or pointer event) occurs outside the content. It accepts one of the following values: - -- `'close'` (default): Closes the Dialog immediately. -- `'ignore'` : Prevents the Dialog from closing. -- `'defer-otherwise-close'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Dialog will close immediately. -- `'defer-otherwise-ignore'` : If an ancestor Bits UI component also implements this prop, it will defer the closing decision to that component. Otherwise, the Dialog will ignore the event and not close. - -To always prevent the Dialog from closing when an interaction occurs outside the content, set the `interactOutsideBehavior` prop to `'ignore'` on `Dialog.Content`: - -```svelte - - -``` - -#### Method 2: `onInteractOutside` For custom handling of outside interactions, you can override the default behavior using the `onInteractOutside` prop: - -```svelte - { - e.preventDefault(); - // do something else instead - }} -> - -``` - -This approach allows you to implement specific behaviors when users interact outside the Dialog content. - -### Best Practices - -- **Scroll Lock**: Consider your use case carefully before disabling scroll lock. It may be necessary for dialogs with scrollable content or for specific UX requirements. -- **Escape Keydown**: Overriding the default escape key behavior should be done thoughtfully. Users often expect the escape key to close modals. -- **Outside Interactions**: Ignoring outside interactions can be useful for important dialogs or multi-step processes, but be cautious not to trap users unintentionally. -- **Accessibility**: Always ensure that any customizations maintain or enhance the dialog's accessibility. -- **User Expectations**: Try to balance custom behaviors with common UX patterns to avoid confusing users. By leveraging these advanced features, you can create highly customized dialog experiences while maintaining usability and accessibility standards. - -## Nested Dialogs - -Dialogs can be nested within each other to create more complex user interfaces: - -```svelte - - - {#snippet title()} - First Dialog - {/snippet} - {#snippet description()} - This is the first dialog. - {/snippet} - - {#snippet title()} - Second Dialog - {/snippet} - {#snippet description()} - This is the second dialog. - {/snippet} - - -``` - -## Svelte Transitions - -The Dialog component can be enhanced with Svelte's built-in transition effects or other animation libraries. - -### Using `forceMount` and `child` Snippets - -To apply Svelte transitions to Dialog components, use the `forceMount` prop in combination with the `child` snippet. This approach gives you full control over the mounting behavior and animation of `Dialog.Content` and `Dialog.Overlay`. - -```svelte - - - - {#snippet child({ props, open })} - {#if open} -
-
- {/if} - {/snippet} -
- - {#snippet child({ props, open })} - {#if open} -
-
- {/if} - {/snippet} -
-
-``` - -In this example: - -- The `forceMount` prop ensures the components are always in the DOM. -- The `child` snippet provides access to the open state and component props. -- Svelte's `#if` block controls when the content is visible. -- Transition directives ( `transition:fade` and `transition:fly` ) apply the animations. - -### Best Practices - -For cleaner code and better maintainability, consider creating custom reusable components that encapsulate this transition logic. - -MyDialogOverlay.svelte - -```svelte - - - {#snippet child({ props, open })} - {#if open} -
- {@render children?.()} -
- {/if} - {/snippet} -
-``` - -You can then use the `MyDialogOverlay` component alongside the other `Dialog` primitives throughout your application: - -```svelte - - - Open - - - - - - -``` - -## Working with Forms - -### Form Submission - -When using the `Dialog` component, often you'll want to submit a form or perform an asynchronous action and then close the dialog. - -This can be done by waiting for the asynchronous action to complete, then programmatically closing the dialog. - -```svelte - - - - - - Confirm your action - Are you sure you want to do this? -
{ - wait(1000).then(() => (open = false)); - }} - > -
- - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Groups - -To group related menu items, you can use the `DropdownMenu.Group` component along with either a `DropdownMenu.GroupHeading` or an `aria-label` attribute on the `DropdownMenu.Group` component. - -```svelte - - File - New - Open - Save - Save As - - - - New - Open - Save - Save As - -``` - -### Group Heading - -The `DropdownMenu.GroupHeading` component must be a child of either a `DropdownMenu.Group` or `DropdownMenu.RadioGroup` component. If used on its own, an error will be thrown during development. - -```svelte - - File - - - - Favorite color - -``` - -## Checkbox Items - -You can use the `DropdownMenu.CheckboxItem` component to create a `menuitemcheckbox` element to add checkbox functionality to menu items. - -```svelte - - - {#snippet children({ checked, indeterminate })} - {#if indeterminate} - - - {:else if checked} - - {/if} - Notifications - {/snippet} - -``` - -The `checked` state does not persist between menu open/close cycles. To persist the state, you must store it in a `$state` variable and pass it to the `checked` prop. - -## Radio Groups - -You can combine the `DropdownMenu.RadioGroup` and `DropdownMenu.RadioItem` components to create a radio group within a menu. - -```svelte - - - Favorite number - {#each values as value} - - {#snippet children({ checked })} - {#if checked} - - {/if} - {value} - {/snippet} - - {/each} - -``` - -The `value` state does not persist between menu open/close cycles. To persist the state, you must store it in a `$state` variable and pass it to the `value` prop. - -## Nested Menus - -You can create nested menus using the `DropdownMenu.Sub` component to create complex menu structures. - -```svelte - - - Item 1 - Item 2 - - Open Sub Menu - - Sub Item 1 - Sub Item 2 - - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `DropdownMenu.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- Item 1 - Item 2 -
-
- {/if} - {/snippet} -
-``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - - - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- -
- - Profile -
-
- - - - - P - -
-
- -
- - Billing -
-
- - - - - B - -
-
- -
- - Settings -
-
- - - - - S - -
-
- - {#snippet children({ checked })} -
- - Notifications -
-
- {#if checked} - - {/if} -
- {/snippet} -
- - -
- - Workspace -
-
- -
-
- - - - {#snippet children({ checked })} - - - HJ - - @huntabyte - {#if checked} - - {/if} - {/snippet} - - - {#snippet children({ checked })} - - - PS - - @pavel_stianko - {#if checked} - - {/if} - {/snippet} - - - {#snippet children({ checked })} - - - CK - - @cokakoala_ - {#if checked} - - {/if} - {/snippet} - - - {#snippet children({ checked })} - - - - TL - - - @thomasglopes - {#if checked} - - {/if} - {/snippet} - - - -
-
-
- {/if} - {/snippet} -
-
-
-``` - -## Custom Anchor - -By default, the `DropdownMenu.Content` is anchored to the `DropdownMenu.Trigger` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector `string` or an `HTMLElement` to the `customAnchor` prop of the `DropdownMenu.Content` component. - -```svelte - -
- - - - - -``` - -## API Reference - -### DropdownMenu.Root - -The root component which manages & scopes the state of the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the menu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the menu's open state changes.`Default: undefined` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DropdownMenu.Trigger - -The button element which toggles the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu trigger is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-trigger` | `''` | Present on the trigger element. | - -### DropdownMenu.Portal - -A component that portals the content of the dropdown menu to the body or a custom target (if provided). - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DropdownMenu.Content - -The content displayed when the dropdown menu is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-dropdown-menu-content-transform-origin` | The transform origin of the dropdown menu content element. | -| `--bits-dropdown-menu-content-available-width` | The available width of the dropdown menu content element. | -| `--bits-dropdown-menu-content-available-height` | The available height of the dropdown menu content element. | -| `--bits-dropdown-menu-anchor-width` | The width of the dropdown menu trigger element. | -| `--bits-dropdown-menu-anchor-height` | The height of the dropdown menu trigger element. | - -### DropdownMenu.ContentStatic - -The content displayed when the dropdown menu is open. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-content` | `''` | Present on the content element. | - -### DropdownMenu.Item - -A menu item within the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu item is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-menu-item` | `''` | Present on the item element. | - -### DropdownMenu.CheckboxItem - -A menu item that can be controlled and toggled like a checkbox. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the checkbox menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `checked` $bindable | `boolean` | The checkbox menu item's checked state.`Default: false` | -| `onCheckedChange` | `function`- (checked: boolean) => void | A callback that is fired when the checkbox menu item's checked state changes.`Default: undefined` | -| `indeterminate` $bindable | `boolean` | Whether the checkbox menu item is in an indeterminate state or not.`Default: false` | -| `onIndeterminateChange` | `function`- (indeterminate: boolean) => void | A callback that is fired when the indeterminate state changes.`Default: undefined` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The checkbox menu item's checked state. | ### DropdownMenu.RadioGroup - -A group of radio menu items, where only one can be checked at a time. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently checked radio menu item.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback that is fired when the radio group's value changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-radio-group` | `''` | Present on the radio group element. | - -### DropdownMenu.RadioItem - -A menu item that can be controlled and toggled like a radio button. It must be a child of a `RadioGroup`. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the radio item. When checked, the parent `RadioGroup`'s value will be set to this value.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the radio menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The radio menu item's checked state. | -| `data-value` | `''` | The value of the radio item. | -| `data-menu-radio-item` | `''` | Present on the radio item element. | - -### DropdownMenu.Separator - -A horizontal line to visually separate menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | The orientation of the separator. | -| `data-menu-separator` | `''` | Present on the separator element. | - -### DropdownMenu.Arrow - -An optional arrow which points to the dropdown menu's anchor/trigger point. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-arrow` | `''` | Present on the arrow element. | - -### DropdownMenu.Group - -A group of menu items. It should be passed an `aria-label` or have a child `DropdownMenu.GroupHeading` component to provide a label for a group of menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-group` | `''` | Present on the group element. | - -### DropdownMenu.GroupHeading - -A heading for a group which will be skipped when navigating with the keyboard. It is used to provide a description for a group of menu items and must be a child of either a `DropdownMenu.Group` or `DropdownMenu.RadioGroup` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-menu-group-heading` | `''` | Present on the group heading element. | - -### DropdownMenu.Sub - -A submenu belonging to the parent dropdown menu. Responsible for managing the state of the submenu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the submenu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the submenu's open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### DropdownMenu.SubTrigger - -A menu item which when pressed or hovered, opens the submenu it is a child of. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the submenu trigger is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-trigger` | `''` | Present on the submenu trigger element. | - -### DropdownMenu.SubContent - -The submenu content displayed when the parent submenu is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - -### DropdownMenu.SubContentStatic - -The submenu content displayed when the parent submenu menu is open. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items when reaching the end of the list when using the keyboard.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - ----------------------------------------------------- - -# Label Documentation - -Identifies or describes associated UI elements. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
- - {#snippet children({ checked, indeterminate })} -
- {#if indeterminate} - - {:else if checked} - - {/if} -
- {/snippet} -
- - Accept terms and conditions - -
-``` - -## Structure - -```svelte - - -``` - -## API Reference - -### Label.Root - -An enhanced label component that can be used with any input. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLLabelElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-label-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Link Preview Documentation - -Displays a summarized preview of a linked content's details or information. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - -
- - HB -
-
-
- -
- -
- - HB -
-
-
-

@huntabyte

-

I do things on the internet.

-
-
- - FL, USA -
-
- - Joined May 2020 -
-
-
-
-
-
-``` - -## Overview - -A component that lets users preview a link before they decide to follow it. This is useful for providing non-essential context or additional information about a link without having to navigate away from the current page. - -##### A note about mobile devices! - -This component is only intended to be used with a mouse or other pointing device. It doesn't respond to touch events, and the preview content cannot be accessed via the keyboard. On touch devices, the link will be followed immediately. As it is not accessible to all users, the preview should not contain vital information. - -## Structure - -```svelte - - - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Opt-out of Floating UI - -When you use the `LinkPreview.Content` component, Bits UI uses [Floating UI](https://floating-ui.com/) to position the content relative to the trigger, similar to other popover-like components. - -You can opt-out of this behavior by instead using the `LinkPreview.ContentStatic` component. This component does not use Floating UI and leaves positioning the content entirely up to you. - -```svelte - - - - - -``` - -##### Heads up! - -The `LinkPreview.Arrow` component is designed to be used with Floating UI and `LinkPreview.Content`, so you may experience unexpected behavior if you attempt to use it with `LinkPreview.ContentStatic`. - -## Custom Anchor - -By default, the `LinkPreview.Content` is anchored to the `LinkPreview.Trigger` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector `string` or an `HTMLElement` to the `customAnchor` prop of the `LinkPreview.Content` component. - -```svelte - -
- - - - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `LinkPreview.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
-
- {/if} - {/snippet} -
-``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - - -
- - HB -
-
-
- - {#snippet child({ open, props, wrapperProps })} - {#if open} -
-
-
- -
- - HB -
-
-
-

@huntabyte

-

I do things on the internet.

-
-
- - FL, USA -
-
- - Joined May 2020 -
-
-
-
-
-
- {/if} - {/snippet} -
-
-``` - -## API Reference - -### LinkPreview\.Root - -The root component used to manage the state of the state of the link preview. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the link preview component.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that fires when the open state changes.`Default: undefined` | -| `openDelay` | `number` | The amount of time in milliseconds to delay opening the preview when hovering over the trigger.`Default: 700` | -| `closeDelay` | `number` | The amount of time in milliseconds to delay closing the preview when the mouse leaves the trigger.`Default: 300` | -| `disabled` | `boolean` | Whether or not the link preview is disabled.`Default: false` | -| `ignoreNonKeyboardFocus` | `boolean` | Whether the link preview should ignore non-keyboard focus.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### LinkPreview\.Trigger - -A component which triggers the opening and closing of the link preview on hover or focus. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLAnchorElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the accordion item is open or closed. | -| `data-link-preview-trigger` | `''` | Present on the trigger element. | - -### LinkPreview\.Content - -The contents of the link preview which are displayed when the preview is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the accordion item is open or closed. | -| `data-link-preview-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--bits-link-preview-content-transform-origin` | The transform origin of the link preview content element. | -| `--bits-link-preview-content-available-width` | The available width of the link preview content element. | -| `--bits-link-preview-content-available-height` | The available height of the link preview content element. | -| `--bits-link-preview-anchor-width` | The width of the link preview trigger element. | -| `--bits-link-preview-anchor-height` | The height of the link preview trigger element. | - -### LinkPreview\.ContentStatic - -The contents of the link preview which are displayed when the preview is open. (Static/No Floating UI) - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the accordion item is open or closed. | -| `data-link-preview-content` | `''` | Present on the content element. | - -### LinkPreview\.Arrow - -An optional arrow element which points to the trigger when the preview is open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-link-preview-arrow` | `''` | Present on the arrow element. | - -### LinkPreview\.Portal - -When used, will render the link preview content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - ----------------------------------------------------- - -# Menubar Documentation - -Organizes and presents a collection of menu options or actions within a horizontal bar. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
- -
- - - File - - - - {#each grids as grid} - - {#snippet children({ checked })} - {grid.label} grid -
- {#if checked} - - {:else} - - {/if} -
- {/snippet} -
- {/each} - - - {#each views as view} - - {#snippet children({ checked })} - {view.label} -
- {#if checked} - - {/if} -
- {/snippet} -
- {/each} -
-
-
-
- - - Edit - - - - - Undo - - - Redo - - - - - Find -
- -
-
- - - Search the web - - - - Find... - - - Find Next - - - Find Previous - - -
- - - Cut - - - Copy - - - Paste - -
-
-
- - - View - - - - {#each showConfigs as config} - - {#snippet children({ checked })} - {config.label} -
- {#if checked} - {@render SwitchOn()} - {:else} - {@render SwitchOff()} - {/if} -
- {/snippet} -
- {/each} - - - Reload - - - Force Reload - - - - Toggle Fullscreen - - - - Hide Sidebar - -
-
-
- - - Profiles - - - - - {#each profiles as profile} - - {#snippet children({ checked })} - {profile.label} -
- {#if checked} - - {/if} -
- {/snippet} -
- {/each} -
- - Edit... - - Add Profile... -
-
-
-
-{#snippet SwitchOn()} -
- -
-{/snippet} -{#snippet SwitchOff()} -
- -
-{/snippet} -``` - -## Structure - -```svelte - - - - - - - - - - - - - {#snippet children({ checked })} - {checked ? "" : ""} - {/snippet} - - - - - {#snippet children({ checked })} - {checked ? "" : ""} - {/snippet} - - - - - - - - - - - - -``` - -## Reusable Components - -If you're planning to use Menubar in multiple places, you can create reusable components that wrap the different parts of the Menubar. - -In the following example, we're creating a reusable `MyMenubarMenu` component that contains the trigger, content, and items of a menu. - -MyMenubarMenu.svelte - -```svelte - - - - {triggerText} - - - - {#each items as item} - - {item.label} - - {/each} - - - -``` - -Now, we can use the `MyMenubarMenu` component within a `Menubar.Root` component to render out the various menus. - -```svelte - - - {#each menubarMenus as { title, items }} - - {/each} - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the menubar. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - - - - - -``` - -## Checkbox Items - -You can use the `Menubar.CheckboxItem` component to create a `menuitemcheckbox` element to add checkbox functionality to menu items. - -```svelte - - - {#snippet children({ checked, indeterminate })} - {#if indeterminate} - - - {:else if checked} - - {/if} - Notifications - {/snippet} - -``` - -## Radio Groups - -You can combine the `Menubar.RadioGroup` and `Menubar.RadioItem` components to create a radio group within a menu. - -```svelte - - - {#each values as value} - - {#snippet children({ checked })} - {#if checked} - - {/if} - {value} - {/snippet} - - {/each} - -``` - -## Nested Menus - -You can create nested menus using the `Menubar.Sub` component to create complex menu structures. - -```svelte - - - Item 1 - Item 2 - - Open Sub Menu - - Sub Item 1 - Sub Item 2 - - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `Menubar.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- Item 1 - Item 2 -
-
- {/if} - {/snippet} -
-``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -## API Reference - -### Menubar.Root - -The root menubar component which manages & scopes the state of the menubar. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently active menu.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback function called when the active menu value changes.`Default: undefined` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menubar menu triggers when navigating with the keyboard.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### Menubar.Menu - -A menu within the menubar. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `string` | The value of this menu within the menubar, used to identify it when determining which menu is active.`Default: undefined` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the submenu's open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Menubar.Trigger - -The button element which toggles the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu trigger is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-trigger` | `''` | Present on the trigger element. | - -### Menubar.Portal - -A component that portals the content of the dropdown menu to the body or a custom target (if provided). - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Menubar.Content - -The content displayed when the dropdown menu is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-menubar-menu-content-transform-origin` | The transform origin of the dropdown menu content element. | -| `--bits-menubar-menu-content-available-width` | The available width of the dropdown menu content element. | -| `--bits-menubar-menu-content-available-height` | The available height of the dropdown menu content element. | -| `--bits-menubar-menu-anchor-width` | The width of the dropdown menu trigger element. | -| `--bits-menubar-menu-anchor-height` | The height of the dropdown menu trigger element. | - -### Menubar.Item - -A menu item within the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the menu item is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-menu-item` | `''` | Present on the item element. | - -### Menubar.CheckboxItem - -A menu item that can be controlled and toggled like a checkbox. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the checkbox menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `checked` $bindable | `boolean` | The checkbox menu item's checked state.`Default: false` | -| `onCheckedChange` | `function`- (checked: boolean) => void | A callback that is fired when the checkbox menu item's checked state changes.`Default: undefined` | -| `indeterminate` $bindable | `boolean` | Whether the checkbox menu item is in an indeterminate state or not.`Default: false` | -| `onIndeterminateChange` | `function`- (indeterminate: boolean) => void | A callback that is fired when the indeterminate state changes.`Default: undefined` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The checkbox menu item's checked state. | ### Menubar.RadioGroup - -A group of radio menu items, where only one can be checked at a time. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently checked radio menu item.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback that is fired when the radio group's value changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-radio-group` | `''` | Present on the radio group element. | - -### Menubar.RadioItem - -A menu item that can be controlled and toggled like a radio button. It must be a child of a `RadioGroup`. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the radio item. When checked, the parent `RadioGroup`'s value will be set to this value.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the radio menu item is disabled. Disabled items cannot be interacted with and are skipped when navigating with the keyboard.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- '' | The radio menu item's checked state. | -| `data-value` | `''` | The value of the radio item. | -| `data-menu-radio-item` | `''` | Present on the radio item element. | - -### Menubar.Separator - -A horizontal line to visually separate menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | The orientation of the separator. | -| `data-menu-separator` | `''` | Present on the separator element. | - -### Menubar.Arrow - -An optional arrow which points to the dropdown menu's anchor/trigger point. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-arrow` | `''` | Present on the arrow element. | - -### Menubar.Group - -A group of menu items. It should be passed an `aria-label` or have a child `Menu.GroupHeading` component to provide a label for a group of menu items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-menu-group` | `''` | Present on the group element. | - -### Menubar.GroupHeading - -A heading for a group which will be skipped when navigating with the keyboard. It is used to provide a heading for a group of menu items and must be a child of either a `Menubar.Group` or `Menubar.RadioGroup` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-menu-group-heading` | `''` | Present on the group heading element. | - -### Menubar.Sub - -A submenu belonging to the parent dropdown menu. Responsible for managing the state of the submenu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the submenu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the submenu's open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Menubar.SubTrigger - -A menu item which when pressed or hovered, opens the submenu it is a child of. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the submenu trigger is disabled.`Default: false` | -| `textValue` | `string` | The text value of the checkbox menu item. This is used for typeahead.`Default: undefined` | -| `onSelect` | `function`- () => void | A callback that is fired when the menu item is selected.`Default: undefined` | -| `closeOnSelect` | `boolean` | Whether or not the menu item should close when selected.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `'vertical'` | | -| `data-highlighted` | `''` | Present when the menu item is highlighted. | -| `data-disabled` | `''` | Present when the menu item is disabled. | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-trigger` | `''` | Present on the submenu trigger element. | - -### Menubar.SubContent - -The submenu content displayed when the parent submenu is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items in when navigating with the keyboard.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - -### Menubar.SubContentStatic - -The submenu content displayed when the parent submenu menu is open. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not to loop through the menu items when reaching the end of the list when using the keyboard.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The open state of the menu or submenu the element controls or belongs to. | -| `data-menu-sub-content` | `''` | Present on the submenu content element. | - ----------------------------------------------------- - -# Meter Documentation - -Display real-time measurements within a defined range. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
-
- Tokens used - {value} / {max} -
- -
-
-
-``` - -While often visually similar, meters and [Progress](/docs/components/progress) bars serve distinct purposes: - -**Meter**: - -- Displays a **static measurement** within a known range (0-100) -- Value can fluctuate up/down based on real-time measurements -- Examples: CPU usage, battery level, sound volume -- Use when showing current state relative to capacity **Progress bar**: - -- Shows **completion status** of a task -- Value only increases as task progresses -- Examples: File upload, installation status, form completion -- Use when tracking advancement toward completion If a progress bar better fits your requirements, check out the [Progress](/docs/components/progress) component. - -## Structure - -```svelte - - -``` - -## Reusable Components - -It's recommended to use the `Meter` primitive to create your own custom meter component that can be used throughout your application. In the example below, we're using the `Meter` primitive to create a more comprehensive meter component. - -```svelte - -
- {label} - {valueLabel} -
- -``` - -You can then use the `MyMeter` component in your application like so: - -+page.svelte - -```svelte - - -``` - -Of course, you'd want to apply your own styles and other customizations to the `MyMeter` component to fit your application's design. - -## Accessibility - -If a visual label is used, the ID of the label element should be pass via the `aria-labelledby` prop to `Meter.Root`. If no visual label is used, the `aria-label` prop should be used to provide a text description of the progress bar. - -Assistive technologies often present `aria-valuenow` as a percentage. If conveying the value of the meter only in terms of a percentage would not be user friendly, the `aria-valuetext` property should be set to a string that makes the meter value understandable. For example, a battery meter value might be conveyed as `aria-valuetext="50% (6 hours) remaining"`. \[[source](https://www.w3.org/WAI/ARIA/apg/patterns/meter/)] - -## API Reference - -### Meter.Root - -The meter component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `max` | `number` | The maximum value of the meter.`Default: 100` | -| `min` | `number` | The minimum value of the meter.`Default: 0` | -| `value` | `number` | The current value of the meter.`Default: 0` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-value` | `''` | The current value of the meter. | -| `data-min` | `''` | The minimum value of the meter. | -| `data-max` | `''` | The maximum value of the meter. | -| `data-meter-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Navigation Menu Documentation - -A list of links that allow users to navigate between pages of a website. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -{#snippet ListItem({ className, title, content, href }: ListItemProps)} -
  • - -
    {title}
    -

    - {content} -

    -
    -
  • -{/snippet} - - - - - Getting started - - -
      -
    • - -
      Bits UI
      -

      - The headless components for Svelte. -

      -
      -
    • - {@render ListItem({ - href: "/docs", - title: "Introduction", - content: "Headless components for Svelte and SvelteKit" - })} - {@render ListItem({ - href: "/docs/getting-started", - title: "Getting Started", - content: "How to install and use Bits UI" - })} - {@render ListItem({ - href: "/docs/styling", - title: "Styling", - content: "How to style Bits UI components" - })} -
    -
    -
    - - - Components - - -
      - {#each components as component (component.title)} - {@render ListItem({ - href: component.href, - title: component.title, - content: component.description - })} - {/each} -
    -
    -
    - - - - Docs - - - -
    -
    -
    -
    - -
    -
    -``` - -## Structure - -```svelte - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -## Usage - -### Vertical - -You can create a vertical menu by using the `orientation` prop. - -```svelte - - -``` - -### Flexible Layouts - -Use the `Viewport` component when you need extra control over where `Content` is rendered. This can be useful when your design requires an adjusted DOM structure or if you need flexibility to achieve advanced animations. Tab focus will be managed automatically. - -```svelte - - - - Item one - Item one content - - - Item two - Item two content - - - - -``` - -### With Indicator - -You can use the optional `Indicator` component to highlight the currently active `Trigger`, which is useful when you want to provide an animated visual cue such as an arrow or highlight to accompany the `Viewport`. - -```svelte - - - - Item one - Item one content - - - Item two - Item two content - - - - - -``` - -### Submenus - -You can create a submenu by nesting your navigation menu and using the `Navigation.Sub` component in place of `NavigationMenu.Root`. Submenus work differently than the `Root` menus and are more similar to [Tabs](/docs/components/tabs) in that one item should always be active, so be sure to assign and pass a `value` prop. - -```svelte - - - - Item one - Item one content - - - Item two - - - - - Sub item one - Sub item one content - - - Sub item two - Sub item two content - - - - - - - -``` - -### Advanced Animation - -We expose `--bits-navigation-menu-viewport-[width|height]` and `data-motion['from-start'|'to-start'|'from-end'|'to-end']` to allow you to animate the `NavigationMenu.Viewport` size and `NavigationMenu.Content` position based on the enter/exit direction. - -Combining these with `position: absolute;` allows you to create smooth overlapping animation effects when moving between items. - -```svelte - - - - Item one - - Item one content - - - - Item two - - Item two content - - - - - -``` - -```css -/* app.css */ -.NavigationMenuContent { - position: absolute; - top: 0; - left: 0; - animation-duration: 250ms; - animation-timing-function: ease; -} -.NavigationMenuContent[data-motion="from-start"] { - animation-name: enter-from-left; -} -.NavigationMenuContent[data-motion="from-end"] { - animation-name: enter-from-right; -} -.NavigationMenuContent[data-motion="to-start"] { - animation-name: exit-to-left; -} -.NavigationMenuContent[data-motion="to-end"] { - animation-name: exit-to-right; -} -.NavigationMenuViewport { - position: relative; - width: var(--bits-navigation-menu-viewport-width); - height: var(--bits-navigation-menu-viewport-height); - transition: - width, - height, - 250ms ease; -} -@keyframes enter-from-right { - from { - opacity: 0; - transform: translateX(200px); - } - to { - opacity: 1; - transform: translateX(0); - } -} -@keyframes enter-from-left { - from { - opacity: 0; - transform: translateX(-200px); - } - to { - opacity: 1; - transform: translateX(0); - } -} -@keyframes exit-to-right { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(200px); - } -} -@keyframes exit-to-left { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(-200px); - } -} -``` - -### Force Mounting - -You may wish for the links in the Navigation Menu to persist in the DOM, regardless of whether the menu is open or not. This is particularly useful for SEO purposes. You can achieve this by using the `forceMount` prop on the `NavigationMenu.Content` and `NavigationMenu.Viewport` components. - -##### Warning - -**Note:** Using `forceMount` requires you to manage the visibility of the elements yourself, using the `data-state` attributes on the `NavigationMenu.Content` and `NavigationMenu.Viewport` components. - -```svelte - - -``` - -```svelte - -{#snippet ListItem({ className, title, content, href }: ListItemProps)} -
  • - -
    {title}
    -

    - {content} -

    -
    -
  • -{/snippet} - - - - - Getting started - - -
      -
    • - -
      Bits UI
      -

      - The headless components for Svelte. -

      -
      -
    • - {@render ListItem({ - href: "/docs", - title: "Introduction", - content: "Headless components for Svelte and SvelteKit" - })} - {@render ListItem({ - href: "/docs/getting-started", - title: "Getting Started", - content: "How to install and use Bits UI" - })} - {@render ListItem({ - href: "/docs/styling", - title: "Styling", - content: "How to style Bits UI components" - })} -
    -
    -
    - - - Components - - -
      - {#each components as component (component.title)} - {@render ListItem({ - href: component.href, - title: component.title, - content: component.description - })} - {/each} -
    -
    -
    - - - - Docs - - - -
    -
    -
    -
    - -
    -
    -``` - -## API Reference - -### NavigationMenu.Root - -The root navigation menu component which manages & scopes the state of the navigation menu. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently active menu.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback function called when the active menu value changes.`Default: undefined` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `skipDelayDuration` | `number` | How much time a user has to enter another trigger without incurring a delay again.`Default: 300` | -| `delayDuration` | `number` | The duration from when the mouse enters a trigger until the content opens.`Default: 200` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the menu.`Default: horizontal` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.List - -A menu within the menubar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLUListElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Item - -A list item within the navigation menu. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `string` | The value of the item.`Default: undefined` | -| `ref` $bindable | `HTMLLiElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Trigger - -The button element which toggles the dropdown menu. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the trigger is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Content - -The content displayed when the dropdown menu is open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Link - -A link within the navigation menu. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `active` | `boolean` | Whether or not the link is active.`Default: false` | -| `onSelect` | `function`- () => void | A callback function called when the link is selected.`Default: undefined` | -| `ref` $bindable | `HTMLAnchorElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Viewport - -The viewport element for the navigation menu, which is used to contain the menu items. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### NavigationMenu.Indicator - -The indicator element for the navigation menu, which is used to indicate the current active item. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | - ----------------------------------------------------- - -# Pagination Documentation - -Facilitates navigation between pages. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - {#snippet children({ pages, range })} -
    - - - -
    - {#each pages as page (page.key)} - {#if page.type === "ellipsis"} -
    - ... -
    - {:else} - - {page.value} - - {/if} - {/each} -
    - - - -
    -

    - Showing {range.start} - {range.end}

    - {/snippet} -
    -``` - -## Structure - -```svelte - - - - {#each pages as page (page.key)} - - {/each} - - -``` - -## Managing Page State - -This section covers how to manage the `page` state of the component. - -### Two-Way Binding - -Use `bind:page` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Ellipsis - -The `pages` snippet prop consists of two types of items: `'page'` and `'ellipsis'`. The `'page'` type represents an actual page number, while the `'ellipsis'` type represents a placeholder for rendering an ellipsis between pages. - -## API Reference - -### Pagination.Root - -The root pagination component which contains all other pagination components. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `count` required | `number` | The total number of items.`Default: undefined` | -| `page` $bindable | `number` | The selected page. You can bind this to a variable to control the selected page from outside the component.`Default: undefined` | -| `onPageChange` | `function`- (page: number) => void | A function called when the selected page changes.`Default: undefined` | -| `perPage` | `number` | The number of items per page.`Default: 1` | -| `siblingCount` | `number` | The number of page triggers to show on either side of the current page.`Default: 1` | -| `loop` | `boolean` | Whether or not the pagination should loop through the items when reaching the end while navigating with the keyboard.`Default: false` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the pagination. This determines how keyboard navigation will work with the component.`Default: horizontal` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | ### Pagination.Page - -A button that triggers a page change. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `page` | `PageItem`- export type Page = { type: "page"; /\*\* The page number the 'PageItem' represents \*/ value: number; } export type Ellipsis = { type: "ellipsis"; } export type PageItem = (Page \| Ellipsis) & { /\*\* Unique key for the item, for svelte #each block \*/ key: string; } | The page item this component represents.`Default: undefined` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-selected` | `''` | Present on the current page element. | -| `data-pagination-page` | `''` | Present on the page trigger element. | - -### Pagination.PrevButton - -The previous button of the pagination. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-pagination-prev-button` | `''` | Present on the previous button element. | - -### Pagination.NextButton - -The next button of the pagination. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-pagination-next-button` | `''` | Present on the next button element. | - ----------------------------------------------------- - -# PIN Input Documentation - -Allows users to input a sequence of one-character alphanumeric inputs. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - {#snippet children({ cells })} -
    - {#each cells.slice(0, 3) as cell} - {@render Cell(cell)} - {/each} -
    -
    -
    -
    -
    - {#each cells.slice(3, 6) as cell} - {@render Cell(cell)} - {/each} -
    - {/snippet} -
    -{#snippet Cell(cell: CellProps)} - - {#if cell.char !== null} -
    - {cell.char} -
    - {/if} - {#if cell.hasFakeCaret} - - {/if} -
    -{/snippet} -``` - -## Overview - -The PIN Input component provides a customizable solution for One-Time Password (OTP), Two-Factor Authentication (2FA), or Multi-Factor Authentication (MFA) input fields. Due to the lack of a native HTML element for these purposes, developers often resort to either basic input fields or custom implementations. This component offers a robust, accessible, and flexible alternative. - -##### Credits - -This component is derived from and would not have been possible without the work done by [Guilherme Rodz](https://x.com/guilhermerodz) with [Input OTP](https://github.com/guilhermerodz/input-otp). - -## Key Features - -- **Invisible Input Technique**: Utilizes an invisible input element for seamless integration with form submissions and browser autofill functionality. -- **Customizable Appearance**: Allows for custom designs while maintaining core functionality. -- **Accessibility**: Ensures keyboard navigation and screen reader compatibility. -- **Flexible Configuration**: Supports various PIN lengths and input types (numeric, alphanumeric). ## Architecture - -1. - **Root Container**: A relatively positioned root element that encapsulates the entire component. -2. - **Invisible Input**: A hidden input field that manages the actual value and interacts with the browser's built-in features. -3. - **Visual Cells**: Customizable elements representing each character of the PIN, rendered as siblings to the invisible input. - -This structure allows for a seamless user experience while providing developers with full control over the visual representation. - -## Structure - -```svelte - - - {#snippet children({ cells })} - {#each cells as cell} - - {/each} - {/snippet} - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Paste Transformation - -The `pasteTransformer` prop allows you to sanitize/transform pasted text. This can be useful for cleaning up pasted text, like removing hyphens or other characters that should not make it into the input. This function should return the sanitized text, which will be used as the new value of the input. - -```svelte - - text.replace(/-/g, "")}> - -``` - -## HTML Forms - -The `PinInput.Root` component is designed to work seamlessly with HTML forms. Simply pass the `name` prop to the `PinInput.Root` component and the input will be submitted with the form. - -### Submit On Complete - -To submit the form when the input is complete, you can use the `onComplete` prop. - -```svelte - -
    - form.submit()}> - -
    -``` - -## Patterns - -You can use the `pattern` prop to restrict the characters that can be entered or pasted into the input. - -##### Note! - -Client-side validation cannot replace server-side validation. Use this in addition to server-side validation for an improved user experience. - -Bits UI exports a few common patterns that you can import and use in your application. - -- `REGEXP_ONLY_DIGITS` \- Only allow digits to be entered. -- `REGEXP_ONLY_CHARS` \- Only allow characters to be entered. -- `REGEXP_ONLY_DIGITS_AND_CHARS` \- Only allow digits and characters to be entered. - -```svelte - - - -``` - -## API Reference - -### PINInput.Root - -The pin input container component. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the input.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback function that is called when the value of the input changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the pin input is disabled.`Default: false` | -| `textalign` | `enum`- 'left' \| 'center' \| 'right' | Where is the text located within the input. Affects click-holding or long-press behavior`Default: 'left'` | -| `maxlength` | `number` | The maximum length of the pin input.`Default: 6` | -| `onComplete` | `function`- (...args: any\[]) => void | A callback function that is called when the input is completely filled.`Default: undefined` | -| `pasteTransformer` | `function`- (text: string) => string | A callback function that is called when the user pastes text into the input. It receives the pasted text as an argument and should return the sanitized text. Useful for cleaning up pasted text, like removing hyphens or other characters that should not make it into the input.`Default: undefined` | -| `inputId` | `string` | Optionally provide an ID to apply to the hidden input element.`Default: undefined` | -| `pushPasswordManagerStrategy` | `enum`- 'increase-width' \| 'none' | Enabled by default, it's an optional strategy for detecting Password Managers in the page and then shifting their badges to the right side, outside the input.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-pin-input-root` | `''` | Present on the root element. | - -### PINInput.Cell - -A single cell of the pin input. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cell` | `object`- type Cell = { /\*\* The character displayed in the cell. \*/ char: string \| null \| undefined; /\*\* Whether the cell is active. \*/ isActive: boolean; /\*\* Whether the cell has a fake caret. \*/ hasFakeCaret: boolean; } | The cell object provided by the `cells` snippet prop from the `PinInput.Root` component.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-active` | `''` | Present when the cell is active. | -| `data-inactive` | `''` | Present when the cell is inactive. | -| `data-pin-input-cell` | `''` | Present on the cell element. | - ----------------------------------------------------- - -# Popover Documentation - -Display supplementary content or information when users interact with specific elements. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - Resize - - - -
    -
    - -
    -
    -

    - Resize image -

    -

    - Resize your photos easily -

    -
    -
    - -
    -
    -
    - Width - - -
    -
    - Height - - -
    -
    - - - -
    -
    -
    -
    -``` - -## Structure - -```svelte - - - - - - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Focus - -### Focus Trap - -By default, when a Popover is opened, focus will be trapped within that Popover. You can disable this behavior by setting the `trapFocus` prop to `false` on the `Popover.Content` component. - -```svelte - - -``` - -### Open Focus - -By default, when a Popover is opened, focus will be set to the first focusable element with the `Popover.Content`. This ensures that users navigating my keyboard end up somewhere within the Popover that they can interact with. - -You can override this behavior using the `onOpenAutoFocus` prop on the `Popover.Content` component. It's *highly* recommended that you use this prop to focus *something* within the Popover's content. - -You'll first need to cancel the default behavior of focusing the first focusable element by cancelling the event passed to the `onOpenAutoFocus` callback. You can then focus whatever you wish. - -```svelte - - - Open Popover - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - - -``` - -### Close Focus - -By default, when a Popover is closed, focus will be set to the trigger element of the Popover. You can override this behavior using the `onCloseAutoFocus` prop on the `Popover.Content` component. - -You'll need to cancel the default behavior of focusing the trigger element by cancelling the event passed to the `onCloseAutoFocus` callback, and then focus whatever you wish. - -```svelte - - - - Open Popover - { - e.preventDefault(); - nameInput?.focus(); - }} - > - - -``` - -## Scroll Lock - -By default, when a Popover is opened, users can still scroll the body and interact with content outside of the Popover. If you wish to lock the body scroll and prevent users from interacting with content outside of the Popover, you can set the `preventScroll` prop to `true` on the `Popover.Content` component. - -```svelte - - -``` - -## Escape Keydown - -By default, when a Popover is open, pressing the `Escape` key will close the dialog. Bits UI provides a couple ways to override this behavior. - -### escapeKeydownBehavior - -You can set the `escapeKeydownBehavior` prop to `'ignore'` on the `Popover.Content` component to prevent the dialog from closing when the `Escape` key is pressed. - -```svelte - - -``` - -### onEscapeKeydown - -You can also override the default behavior by cancelling the event passed to the `onEscapeKeydown` callback on the `Popover.Content` component. - -```svelte - e.preventDefault()}> - -``` - -## Interact Outside - -By default, when a Popover is open, pointer down events outside the content will close the popover. Bits UI provides a couple ways to override this behavior. - -### interactOutsideBehavior - -You can set the `interactOutsideBehavior` prop to `'ignore'` on the `Popover.Content` component to prevent the dialog from closing when the user interacts outside the content. - -```svelte - - -``` - -### onInteractOutside - -You can also override the default behavior by cancelling the event passed to the `onInteractOutside` callback on the `Popover.Content` component. - -```svelte - e.preventDefault()}> - -``` - -## Custom Anchor - -By default, the `Popover.Content` is anchored to the `Popover.Trigger` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector `string` or an `HTMLElement` to the `customAnchor` prop of the `Popover.Content` component. - -```svelte - -
    - - - - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `Popover.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    -
    -
    - {/if} - {/snippet} -
    -``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - - Resize - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    -
    -
    - -
    -
    -

    - Resize image -

    -

    - Resize your photos easily -

    -
    -
    - -
    -
    -
    - Width - - -
    -
    - Height - - -
    -
    - - - -
    -
    -
    - {/if} - {/snippet} -
    -
    -
    -``` - -## API Reference - -### Popover.Root - -The root component used to manage the state of the state of the popover. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the link popover component.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that fires when the open state changes.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Popover.Trigger - -A component which toggles the opening and closing of the popover on press. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-trigger` | `''` | Present on the trigger element. | - -### Popover.Content - -The contents of the popover which are displayed when the popover is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-popover-content-transform-origin` | The transform origin of the popover content element. | -| `--bits-popover-content-available-width` | The available width of the popover content element. | -| `--bits-popover-content-available-height` | The available height of the popover content element. | -| `--bits-popover-anchor-width` | The width of the popover trigger element. | -| `--bits-popover-anchor-height` | The height of the popover trigger element. | - -### Popover.ContentStatic - -The contents of the popover which are displayed when the popover is open. (Static/No Floating UI) - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | Whether the popover is open or closed. | -| `data-popover-content` | `''` | Present on the content element. | - -### Popover.Close - -A button which closes the popover when pressed and is typically placed in the content. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-popover-close` | `''` | Present on the close button. | - -### Popover.Arrow - -An optional arrow element which points to the trigger when the popover is open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-arrow` | `''` | Present on the arrow element. | -| `data-popover-arrow` | `''` | Present on the arrow element. | - -### Popover.Portal - -When used, will render the popover content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - ----------------------------------------------------- - -# Progress Documentation - -Visualizes the progress or completion status of a task or process. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    -
    - Uploading file... - {value}% -
    - -
    -
    -
    -``` - -While often visually similar, progress bars and [Meters](/docs/components/meter) serve distinct purposes: - -**Progress**: - -- Shows **completion status** of a task -- Value only increases as task progresses -- Examples: File upload, installation status, form completion -- Use when tracking advancement toward completion **Meter**: - -- Displays a **static measurement** within a known range (0-100) -- Value can fluctuate up/down based on real-time measurements -- Examples: CPU usage, battery level, sound volume -- Use when showing current state relative to capacity If a meter better fits your requirements, check out the [Meter](/docs/components/meter) component. - -## Structure - -```svelte - - -``` - -## Reusable Components - -It's recommended to use the `Progress` primitive to create your own custom meter component that can be used throughout your application. In the example below, we're using the `Progress` primitive to create a more comprehensive meter component. - -```svelte - -
    - {label} - {valueLabel} -
    - -``` - -You can then use the `MyProgress` component in your application like so: - -+page.svelte - -```svelte - - -``` - -Of course, you'd want to apply your own styles and other customizations to the `MyProgress` component to fit your application's design. - -## Accessibility - -If a visual label is used, the ID of the label element should be pass via the `aria-labelledby` prop to `Progress.Root`. If no visual label is used, the `aria-label` prop should be used to provide a text description of the progress bar. - -## API Reference - -### Progress.Root - -The progress bar component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `max` | `number` | The maximum value of the progress bar.`Default: 100` | -| `min` | `number` | The minimum value of the progress bar.`Default: 0` | -| `value` | `number \| null` | The current value of the progress bar. If set to `null``Default: 0` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-value` | `''` | The current value of the progress bar. | -| `data-state` | `enum`- '' | The current state of the progress bar. | -| `data-min` | `''` | The minimum value of the progress bar. | -| `data-max` | `''` | The maximum value of the progress bar. | -| `data-indeterminate` | `''` | Present when the value is `null`. | -| `data-progress-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Radio Group Documentation - -Allows users to select a single option from a list of mutually exclusive choices. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - -
    - - Amazing -
    -
    - - Average -
    -
    - - Terrible -
    -
    -``` - -## Structure - -```svelte - - - - {#snippet children({ checked })} - {#if checked} - - {/if} - {/snippet} - - -``` - -## Reusable Components - -It's recommended to use the `RadioGroup` primitives to create your own custom components that can be used throughout your application. - -In the example below, we're creating a custom `MyRadioGroup` component that takes in an array of items and renders a radio group with those items along with a [`Label`](/docs/components/label) component for each item. - -MyRadioGroup.svelte - -```svelte - - - {#each items as item} - {@const id = useId()} -
    - - {#snippet children({ checked })} - {#if checked} - - {/if} - {/snippet} - - {item.label} -
    - {/each} -
    -``` - -You can then use the `MyRadioGroup` component in your application like so: - -+page.svelte - -```svelte - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## HTML Forms - -If you set the `name` prop on the `RadioGroup.Root` component, a hidden input element will be rendered to submit the value of the radio group to a form. - -```svelte - - -``` - -### Required - -To make the hidden input element `required` you can set the `required` prop on the `RadioGroup.Root` component. - -```svelte - - -``` - -## Disabling Items - -You can disable a radio group item by setting the `disabled` prop to `true`. - -```svelte -Apple -``` - -## Orientation - -The `orientation` prop is used to determine the orientation of the radio group, which influences how keyboard navigation will work. - -When the `orientation` is set to `'vertical'`, the radio group will navigate through the items using the `ArrowUp` and `ArrowDown` keys. When the `orientation` is set to `'horizontal'`, the radio group will navigate through the items using the `ArrowLeft` and `ArrowRight` keys. - -```svelte - - - - -``` - -## API Reference - -### RadioGroup.Root - -The radio group component used to group radio items under a common name for form submission. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The value of the currently selected radio item. You can bind to this value to control the radio group's value from outside the component.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback that is fired when the radio group's value changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the radio group is disabled. This prevents the user from interacting with it.`Default: false` | -| `required` | `boolean` | Whether or not the radio group is required.`Default: false` | -| `name` | `string` | The name of the radio group used in form submission. If provided, a hidden input element will be rendered to submit the value of the radio group.`Default: undefined` | -| `loop` | `boolean` | Whether or not the radio group should loop through the items when navigating with the arrow keys.`Default: false` | -| `orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the radio group. This will determine how keyboard navigation will work within the component.`Default: 'vertical'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- '' | The orientation of the radio group. | -| `data-radio-group-root` | `''` | Present on the root element. | - -### RadioGroup.Item - -An radio item, which must be a child of the `RadioGroup.Root` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the radio item. This should be unique for each radio item in the group.`Default: undefined` | -| `disabled` | `boolean` | Whether the radio item is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the radio item is disabled. | -| `data-value` | `''` | The value of the radio item. | -| `data-state` | `enum`- '' | The radio item's checked state. | -| `data-orientation` | `enum`- '' | The orientation of the parent radio group. | -| `data-radio-group-item` | `''` | Present on the radio item element. | - ----------------------------------------------------- - -# Range Calendar Documentation - -Presents a calendar view tailored for selecting date ranges. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - {#snippet children({ months, weekdays })} - - - - - - - - - -
    - {#each months as month} - - - - {#each weekdays as day} - -
    {day.slice(0, 2)}
    -
    - {/each} -
    -
    - - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {date.day} - - - {/each} - - {/each} - -
    - {/each} -
    - {/snippet} -
    -``` - -##### Heads up! - -Before diving into this component, it's important to understand how dates/times work in Bits UI. Please read the [Dates](/docs/dates) documentation to learn more! - -## Structure - -```svelte - - - {#snippet children({ months, weekdays })} - - - - - - {#each months as month} - - - - {#each weekdays as day} - - {day} - - {/each} - - - - {#each month.weeks as weekDates} - - {#each weekDates as date} - - - - {/each} - - {/each} - - - {/each} - {/snippet} - -``` - -## API Reference - -### RangeCalendar.Root - -The root range calendar component which contains all other calendar components. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `value` $bindable | `DateRange`- import type { DateValue } from "@internationalized/date"; type DateRange = { start: DateValue \| undefined; end: DateValue \| undefined; }; | The selected date range.`Default: undefined` | -| `onValueChange` | `function`- (range: DateRange) => void | A function that is called when the selected date range changes.`Default: undefined` | -| `placeholder` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The placeholder date, which is used to determine what month to display when no date is selected. This updates as the user navigates the calendar, and can be used to programmatically control the calendar's view.`Default: undefined` | -| `onPlaceholderChange` | `function`- (date: DateValue) => void | A function that is called when the placeholder date changes.`Default: undefined` | -| `pagedNavigation` | `boolean` | Whether or not to use paged navigation for the calendar. Paged navigation causes the previous and next buttons to navigate by the number of months displayed at once, rather than by one month.`Default: false` | -| `preventDeselect` | `boolean` | Whether or not to prevent the user from deselecting a date without selecting another date first.`Default: false` | -| `weekdayFormat` | `enum`- 'narrow' \| 'short' \| 'long' | The format to use for the weekday strings provided via the `weekdays` slot prop.`Default: 'narrow'` | -| `weekStartsOn` | `number` | The day of the week to start the calendar on. 0 is Sunday, 1 is Monday, etc.`Default: 0` | -| `calendarLabel` | `string` | The accessible label for the calendar.`Default: undefined` | -| `fixedWeeks` | `boolean` | Whether or not to always display 6 weeks in the calendar.`Default: false` | -| `isDateDisabled` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is disabled.`Default: undefined` | -| `isDateUnavailable` | `function`- (date: DateValue) => boolean | A function that returns whether or not a date is unavailable.`Default: undefined` | -| `maxValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The maximum date that can be selected.`Default: undefined` | -| `minValue` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The minimum date that can be selected.`Default: undefined` | -| `locale` | `string` | The locale to use for formatting dates.`Default: 'en'` | -| `numberOfMonths` | `number` | The number of months to display at once.`Default: 1` | -| `disabled` | `boolean` | Whether or not the accordion is disabled.`Default: false` | -| `readonly` | `boolean` | Whether or not the calendar is readonly.`Default: false` | -| `disableDaysOutsideMonth` | `boolean` | Whether or not to disable days outside the current month.`Default: false` | -| `onStartValueChange` | `function`- (value: DateValue) => void | A function that is called when the start date changes.`Default: undefined` | -| `onEndValueChange` | `function`- (value: DateValue) => void | A function that is called when the end date changes.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-invalid` | `''` | Present on the root element when the calendar is invalid. | -| `data-disabled` | `''` | Present on the root element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the root element when the calendar is readonly. | -| `data-calendar-root` | `''` | Present on the root element. | - -### RangeCalendar.Header - -The header of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the header element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the header element when the calendar is readonly. | -| `data-calendar-header` | `''` | Present on the header element. | - -### RangeCalendar.Heading - -The heading of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the heading element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the heading element when the calendar is readonly. | -| `data-calendar-heading` | `''` | Present on the heading element. | - -### RangeCalendar.NextButton - -The next button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the next button element when the calendar or this button is disabled. | -| `data-calendar-next-button` | `''` | Present on the next button element. | - -### RangeCalendar.PrevButton - -The previous button of the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the prev button element when the calendar or this button is disabled. | -| `data-calendar-prev-button` | `''` | Present on the prev button element. | - -### RangeCalendar.Cell - -A cell in the calendar grid. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `date` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The date for the cell.`Default: undefined` | -| `month` | `DateValue`- import type { CalendarDate, CalendarDateTime, ZonedDateTime } from "@internationalized/date"; type DateValue = CalendarDate \| CalendarDateTime \| ZonedDateTime | The current month the date is being displayed in.`Default: undefined` | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-cell` | `''` | Present on the cell element. | -| `data-selection-start` | `''` | Present when the cell is the start of a selection. | -| `data-selection-end` | `''` | Present when the cell is the end of a selection. | -| `data-highlighted` | `''` | Present when the cell is highlighted within a range. | - -### RangeCalendar.Day - -A day in the calendar grid. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present when the day is disabled. | -| `data-unavailable` | `''` | Present when the day is unavailable. | -| `data-today` | `''` | Present when the day is today. | -| `data-outside-month` | `''` | Present when the day is outside the current month. | -| `data-outside-visible-months` | `''` | Present when the day is outside the visible months. | -| `data-focused` | `''` | Present when the day is focused. | -| `data-selected` | `''` | Present when the day is selected. | -| `data-value` | `''` | The date in the format "YYYY-MM-DD". | -| `data-calendar-cell` | `''` | Present on the cell element. | -| `data-selection-start` | `''` | Present when the cell is the start of a selection. | -| `data-selection-end` | `''` | Present when the cell is the end of a selection. | -| `data-highlighted` | `''` | Present when the cell is highlighted within a range. | - -### RangeCalendar.Grid - -The grid of dates in the calendar, typically representing a month. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid` | `''` | Present on the grid element. | - -### RangeCalendar.GridBody - -The body of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid element when the calendar is readonly. | -| `data-calendar-grid-body` | `''` | Present on the grid body element. | - -### RangeCalendar.GridHead - -The head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableSectionElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid head element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid head element when the calendar is readonly. | -| `data-calendar-grid-head` | `''` | Present on the grid head element. | - -### RangeCalendar.GridRow - -A row in the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableRowElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the grid row element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the grid row element when the calendar is readonly. | -| `data-calendar-grid-row` | `''` | Present on the grid row element. | - -### RangeCalendar.HeadCell - -A cell in the head of the grid of dates in the calendar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLTableCellElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-disabled` | `''` | Present on the head cell element when the calendar is disabled. | -| `data-readonly` | `''` | Present on the head cell element when the calendar is readonly. | -| `data-calendar-head-cell` | `''` | Present on the head cell element. | - ----------------------------------------------------- - -# Scroll Area Documentation - -Provides a consistent scroll area across platforms. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - -

    - Scroll Area -

    -

    - Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos - impedit rem, repellat deserunt ducimus quasi nisi voluptatem cumque - aliquid esse ea deleniti eveniet incidunt! Deserunt minus laborum - accusamus iusto dolorum. Lorem ipsum dolor sit, amet consectetur - adipisicing elit. Blanditiis officiis error minima eos fugit voluptate - excepturi eveniet dolore et, ratione impedit consequuntur dolorem hic quae - corrupti autem? Dolorem, sit voluptatum. -

    -
    - - - - - - - -
    -``` - -## Structure - -```svelte - - - - - - - - - - - - -``` - -## Reusable Components - -If you're planning to use the Scroll Area throughout your application, it's recommended to create a reusable component to reduce the amount of code you need to write each time. - -This example shows you how to create a Scroll Area component that accepts a few custom props that make it more capable. - -MyScrollArea.svelte - -```svelte - -{#snippet Scrollbar({ orientation }: { orientation: "vertical" | "horizontal" })} - - - -{/snippet} - - - {@render children?.()} - - {#if orientation === "vertical" || orientation === "both"} - {@render Scrollbar({ orientation: "vertical" })} - {/if} - {#if orientation === "horizontal" || orientation === "both"} - {@render Scrollbar({ orientation: "horizontal" })} - {/if} - - -``` - -We'll use this custom component in the following examples to demonstrate how to customize the behavior of the Scroll Area. - -## Scroll Area Types - -### Hover - -The `hover` type is the default type of the scroll area, demonstrated in the featured example above. It only shows scrollbars when the user hovers over the scroll area and the content is larger than the viewport. - -```svelte - - -``` - -### Scroll - -The `scroll` type displays the scrollbars when the user scrolls the content. This is similar to the behavior of MacOS. - -```svelte - - -``` - -### Auto - -The `auto` type behaves similarly to your typical browser scrollbars. When the content is larger than the viewport, the scrollbars will appear and remain visible at all times. - -```svelte - - -``` - -### Always - -The `always` type behaves as if you set `overflow: scroll` on the scroll area. Scrollbars will always be visible, even when the content is smaller than the viewport. We've also set the `orientation` prop on the `MyScrollArea` to `'both'` to ensure both scrollbars are rendered. - -```svelte - - -``` - -## Customizing the Hide Delay - -You can customize the hide delay of the scrollbars using the `scrollHideDelay` prop. - -```svelte - - -``` - -## API Reference - -### ScrollArea.Root - -The container of all scroll area components. Overflow is hidden on this element to prevent double scrollbars. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | `enum`- 'hover' \| 'scroll' \| 'auto' \| 'always' | The type of scroll area.`Default: 'hover'` | -| `scrollHideDelay` | `number` | The delay in milliseconds before the scroll area hides itself when using `'hover'` or `'scroll'` type.`Default: 600` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ---------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-scroll-area-root` | `''` | Present on the root element. | - -### ScrollArea.Viewport - -The component which wraps the content and is responsible for computing the scroll area size. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -| Data Attribute | Value | Description | -| -------------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-scroll-area-viewport` | `''` | Present on the viewport element. | - -### ScrollArea.Scrollbar - -A scrollbar of the scroll area. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orientation` required | `enum`- 'horizontal' \| 'vertical' | The orientation of the scrollbar.`Default: undefined` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The visibility state of the scrollbar | -| `data-scroll-area-scrollbar-x` | `''` | Present on the `'horizontal'` scrollbar element. | -| `data-scroll-area-scrollbar-y` | `''` | Present on the `'vertical'` scrollbar element. | - -### ScrollArea.Thumb - -A thumb of a scrollbar in the scroll area. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The visibility state of the scrollbar | -| `data-scroll-area-thumb-x` | `''` | Present on the `'horizontal'` thumb element. | -| `data-scroll-area-thumb-y` | `''` | Present on the `'vertical'` thumb element. | - -### ScrollArea.Corner - -The corner element between the X and Y scrollbars. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-scroll-area-corner` | `''` | Present on the corner element. | - ----------------------------------------------------- - -# Select Documentation - -Enables users to choose from a list of options presented in a dropdown. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - (value = v)} items={themes}> - - - {selectedLabel} - - - - - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
    - -
    - {/if} - {/snippet} -
    - {/each} -
    - - - -
    -
    -
    -``` - -## Overview - -The Select component provides users with a selectable list of options. It's designed to offer an enhanced selection experience with features like typeahead search, keyboard navigation, and customizable grouping. This component is particularly useful for scenarios where users need to choose from a predefined set of options, offering more functionality than a standard select element. - -## Key Features - -- **Typeahead Search**: Users can quickly find options by typing -- **Keyboard Navigation**: Full support for keyboard interactions, allowing users to navigate through options using arrow keys, enter to select, and more. -- **Grouped Options**: Ability to organize options into logical groups, enhancing readability and organization of large option sets. -- **Scroll Management**: Includes scroll up/down buttons for easy navigation in long lists. -- **Accessibility**: Built-in ARIA attributes and keyboard support ensure compatibility with screen readers and adherence to accessibility standards. -- **Portal Support**: Option to render the select content in a portal, preventing layout issues in complex UI structures. ## Architecture - -The Select component is composed of several sub-components, each with a specific role: - -- **Root**: The main container component that manages the state and context for the combobox. -- **Trigger**: The button or element that opens the dropdown list. -- **Portal**: Responsible for portalling the dropdown content to the body or a custom target. -- **Group**: A container for grouped items, used to group related items. -- **GroupHeading**: A heading for a group of items, providing a descriptive label for the group. -- **Item**: An individual item within the list. -- **Separator**: A visual separator between items. -- **Content**: The dropdown container that displays the items. It uses [Floating UI](https://floating-ui.com/) - to position the content relative to the trigger. -- **ContentStatic** (Optional): An alternative to the Content component, that enables you to opt-out of Floating UI and position the content yourself. -- **Viewport**: The visible area of the dropdown content, used to determine the size and scroll behavior. -- **ScrollUpButton**: A button that scrolls the content up when the content is larger than the viewport. -- **ScrollDownButton**: A button that scrolls the content down when the content is larger than the viewport. -- **Arrow**: An arrow element that points to the trigger when using the `Combobox.Content` component. - -## Structure - -Here's an overview of how the Select component is structured in code: - -```svelte - - - - - - - - - - - - - - - - - -``` - -## Reusable Components - -As you can see from the structure above, there are a number of pieces that make up the `Select` component. These pieces are provided to give you maximum flexibility and customization options, but can be a burden to write out everywhere you need to use a select in your application. - -To ease this burden, it's recommended to create your own reusable select component that wraps the primitives and provides a more convenient API for your use cases. - -Here's an example of how you might create a reusable `MySelect` component that receives a list of options and renders each of them as an item. - -MySelect.svelte - -```svelte - - - - - {selectedLabel ? selectedLabel : placeholder} - - - - up - - {#each items as { value, label, disabled } (value)} - - {#snippet children({ selected })} - {selected ? "" : ""} - {label} - {/snippet} - - {/each} - - down - - - -``` - -You can then use the `MySelect` component throughout your application like so: - -```svelte - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Multiple Selection - -The `type` prop can be set to `'multiple'` to allow multiple items to be selected at a time. - -```svelte - - - -``` - -```svelte - - - - - - {selectedLabel} - - - - - - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
    - -
    - {/if} - {/snippet} -
    - {/each} -
    - - - -
    -
    -
    -``` - -## Opt-out of Floating UI - -When you use the `Select.Content` component, Bits UI uses [Floating UI](https://floating-ui.com/) to position the content relative to the trigger, similar to other popover-like components. - -You can opt-out of this behavior by instead using the `Select.ContentStatic` component. - -```svelte - - - - - - - - - - - - - - - - -``` - -When using this component, you'll need to handle the positioning of the content yourself. Keep in mind that using `Select.Portal` alongside `Select.ContentStatic` may result in some unexpected positioning behavior, feel free to not use the portal or work around it. - -## Custom Anchor - -By default, the `Select.Content` is anchored to the `Select.Trigger` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector string or an `HTMLElement` to the `customAnchor` prop of the `Select.Content` component. - -```svelte - -
    - - - - - -``` - -## What is the Viewport? - -The `Select.Viewport` component is used to determine the size of the content in order to determine whether or not the scroll up and down buttons should be rendered. - -If you wish to set a minimum/maximum height for the select content, you should apply it to the `Select.Viewport` component. - -## Scroll Up/Down Buttons - -The `Select.ScrollUpButton` and `Select.ScrollDownButton` components are used to render the scroll up and down buttons when the select content is larger than the viewport. - -You must use the `Select.Viewport` component when using the scroll buttons. - -### Custom Scroll Delay - -The initial and subsequent scroll delays can be controlled using the `delay` prop on the buttons. - -For example, we can use the [`cubicOut`](https://svelte.dev/docs/svelte/svelte-easing#cubicOut) easing function from Svelte to create a smooth scrolling effect that speeds up over time. - -```svelte - - (value = v)} items={themes}> - - - {selectedLabel} - - - - - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
    - -
    - {/if} - {/snippet} -
    - {/each} -
    - - - -
    -
    -
    -``` - -## Native Scrolling/Overflow - -If you don't want to use the [scroll buttons](#scroll-updown-buttons) and prefer to use the standard scrollbar/overflow behavior, you can omit the `Select.Scroll[Up|Down]Button` components and the `Select.Viewport` component. - -You'll need to set a height on the `Select.Content` component and appropriate `overflow` styles to enable scrolling. - -## Scroll Lock - -By default, when a user opens the select, scrolling outside the content will not be disabled. You can override this behavior by setting the `preventScroll` prop to `true`. - -```svelte - - -``` - -## Highlighted Items - -The Select component follows the [WAI-ARIA descendant pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox) for highlighting items. This means that the `Select.Trigger` retains focus the entire time, even when navigating with the keyboard, and items are highlighted as the user navigates them. - -### Styling Highlighted Items - -You can use the `data-highlighted` attribute on the `Select.Item` component to style the item differently when it is highlighted. - -### onHighlight / onUnhighlight - -To trigger side effects when an item is highlighted or unhighlighted, you can use the `onHighlight` and `onUnhighlight` props. - -```svelte - console.log('I am highlighted!')} onUnhighlight={() => console.log('I am unhighlighted!')} /> - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `Select.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    -
    -
    - {/if} - {/snippet} -
    -``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content component that handles this logic if you intend to use this approach. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - - - {selectedLabel} - - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
    - -
    - {/if} - {/snippet} -
    - {/each} -
    - - - -
    -
    - {/if} - {/snippet} -
    -
    -
    -``` - -## API Reference - -### Select.Root - -The root select component which manages & scopes the state of the select. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `type` required | `enum`- 'single' \| 'multiple' | The type of selection to use for the select.`Default: undefined` | -| `value` $bindable | `union`- string \| string\[] | The value of the select. When the type is `'single'`, this should be a string. When the type is `'multiple'`, this should be an array of strings.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void \| (value: string\[]) => void | A callback that is fired when the select value changes. When the type is `'single'`, the argument will be a string. When the type is `'multiple'`, the argument will be an array of strings.`Default: undefined` | -| `open` $bindable | `boolean` | The open state of the select menu.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that is fired when the select menu's open state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the select component is disabled.`Default: false` | -| `name` | `string` | The name to apply to the hidden input element for form submission. If provided, a hidden input element will be rendered to submit the value of the select.`Default: undefined` | -| `required` | `boolean` | Whether or not the select menu is required.`Default: false` | -| `scrollAlignment` | `enum`- 'nearest' \| 'center' | The alignment of the highlighted item when scrolling.`Default: 'nearest'` | -| `loop` | `boolean` | Whether or not the select menu should loop through items.`Default: false` | -| `allowDeselect` | `boolean` | Whether or not the user can deselect the selected item by pressing it in a single select.`Default: false` | -| `items` | `object`- { value: string; label: string; disabled?: boolean}\[] | Optionally provide an array of `value` and `label` pairs that will be used to match and trigger selection when the trigger is focused and a key is pressed while the content is closed. Additionally, this will be used for form autofill when the type is single.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Select.Trigger - -A button which toggles the select's open state. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The select's open state. | -| `data-placeholder` | `''` | Present when the select does not have a value. | -| `data-disabled` | `''` | Present when the select is disabled. | -| `data-select-trigger` | `''` | Present on the trigger element. | - -### Select.Content - -The element which contains the select's items. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: false` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not the select should loop through items when reaching the end.`Default: false` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The select's open state. | -| `data-select-content` | `''` | Present on the content element. | - -| CSS Variable | Description | -| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--bits-select-content-transform-origin` | The transform origin of the select content element. | -| `--bits-select-content-available-width` | The available width of the select content element. | -| `--bits-select-content-available-height` | The available height of the select content element. | -| `--bits-select-anchor-width` | The width of the select trigger element. | -| `--bits-select-anchor-height` | The height of the select trigger element. | - -### Select.ContentStatic - -The element which contains the select's items. (Static/No Floating UI) - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onOpenAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is opened. Can be prevented.`Default: undefined` | -| `onCloseAutoFocus` | `function`- (event: Event) => void | Event handler called when auto-focusing the content as it is closed. Can be prevented.`Default: undefined` | -| `trapFocus` | `boolean` | Whether or not to trap the focus within the content when open.`Default: true` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `preventOverflowTextSelection` | `boolean` | When `true`, prevents the text selection from overflowing the bounds of the element.`Default: true` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `loop` | `boolean` | Whether or not the select should loop through items when reaching the end.`Default: false` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'open' \| 'closed' | The select's open state. | -| `data-select-content` | `''` | Present on the content element. | - -### Select.Portal - -When used, will render the select content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Select.Item - -A select item, which must be a child of the `select.Content` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the item.`Default: undefined` | -| `label` | `string` | The label of the item, which is what the list will be filtered by using typeahead behavior.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the select item is disabled. This will prevent interaction/selection.`Default: false` | -| `onHighlight` | `function`- () => void | A callback that is fired when the item is highlighted.`Default: undefined` | -| `onUnhighlight` | `function`- () => void | A callback that is fired when the item is unhighlighted.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-value` | `string` | The value of the select item. | -| `data-label` | `string` | The label of the select item. | -| `data-disabled` | `''` | Present when the item is disabled. | -| `data-highlighted` | `''` | Present when the item is highlighted, which is either via keyboard navigation of the menu or hover. | -| `data-selected` | `''` | Present when the item is selected. | -| `data-select-item` | `''` | Present on the item element. | - -### Select.Viewport - -An optional element to track the scroll position of the select for rendering the scroll up/down buttons. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| --------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-select-viewport` | `''` | Present on the viewport element. | - -### Select.ScrollUpButton - -An optional scroll up button element to improve the scroll experience within the select. Should be used in conjunction with the `select.Viewport` component. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `delay` | `function`- (tick: number) => number | Controls the initial delay (tick 0) and delay between auto-scrolls in milliseconds.`Default: () => 50` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-select-scroll-up-button` | `''` | Present on the scroll up button element. | - -### Select.ScrollDownButton - -An optional scroll down button element to improve the scroll experience within the select. Should be used in conjunction with the `select.Viewport` component. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `delay` | `function`- (tick: number) => number | Controls the initial delay (tick 0) and delay between auto-scrolls in milliseconds.`Default: () => 50` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-select-scroll-down-button` | `''` | Present on the scroll down button element. | - -### Select.Group - -A group of related select items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-select-group` | `''` | Present on the group element. | - -### Select.GroupHeading - -A heading for the parent select group. This is used to describe a group of related select items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-select-group-heading` | `''` | Present on the group heading element. | - -### Select.Arrow - -An optional arrow element which points to the content when open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-arrow` | `''` | Present on the arrow element. | - ----------------------------------------------------- - -# Separator Documentation - -Visually separates content or UI elements for clarity and organization. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    -
    -

    Bits UI

    -

    - Headless UI components for Svelte. -

    -
    - -
    -
    Blog
    - -
    Docs
    - -
    Source
    -
    -
    -``` - -## Structure - -```svelte - - -``` - -## API Reference - -### Separator.Root - -An element used to separate content. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the separator.`Default: 'horizontal'` | -| `decorative` | `boolean` | Whether the separator is decorative or not, which will determine if it is announced by screen readers.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the separator. | -| `data-separator-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Slider Documentation - -Allows users to select a value from a continuous range by sliding a handle. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    - - {#snippet children()} - - - - - {/snippet} - -
    -``` - -## Structure - -```svelte - - - - - - -``` - -## Reusable Components - -Bits UI provides primitives that enable you to build your own custom slider component that can be reused throughout your application. - -Here's an example of how you might create a reusable `MySlider` component. - -MyMultiSlider.svelte - -```svelte - - - - {#snippet children({ thumbs, ticks })} - - {#each thumbs as index} - - {/each} - {#each ticks as index} - - {/each} - {/snippet} - -``` - -You can then use the `MySlider` component in your application like so: - -```svelte - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Value Commit - -You can use the `onValueCommit` prop to be notified when the user finishes dragging the thumb and the value changes. This is different than the `onValueChange` callback because it waits until the user stops dragging before calling the callback, where the `onValueChange` callback is called as the user dragging. - -```svelte - { - console.log("user is done sliding!", v); - }} -/> -``` - -## Multiple Thumbs and Ticks - -If the `value` prop has more than one value, the slider will render multiple thumbs. You can also use the `ticks` snippet prop to render ticks at specific intervals - -```svelte - - - {#snippet children({ ticks, thumbs })} - - {#each thumbs as index (index)} - - {/each} - {#each ticks as index (index)} - - {/each} - {/snippet} - -``` - -To determine the number of ticks that will be rendered, you can simply divide the `max` value by the `step` value. - -```svelte - -
    - - {#snippet children({ ticks, thumbs })} - - - - {#each thumbs as thumb} - - {/each} - {#each ticks as tick} - - {/each} - {/snippet} - -
    -``` - -## Single Type - -Set the `type` prop to `"single"` to allow only one slider handle. - -```svelte - -``` - -```svelte - -
    - - {#snippet children()} - - - - - {/snippet} - -
    -``` - -## Multiple Type - -Set the `type` prop to `"multiple"` to allow multiple slider handles. - -```svelte - -``` - -```svelte - -
    - - {#snippet children({ thumbs })} - - - - {#each thumbs as index} - - {/each} - {/snippet} - -
    -``` - -## Vertical Orientation - -You can use the `orientation` prop to change the orientation of the slider, which defaults to `"horizontal"`. - -```svelte - - -``` - -## RTL Support - -You can use the `dir` prop to change the reading direction of the slider, which defaults to `"ltr"`. - -```svelte - - -``` - -## Auto Sort - -By default, the slider will sort the values from smallest to largest, so if you drag a smaller thumb to a larger value, the value of that thumb will be updated to the larger value. - -You can disable this behavior by setting the `autoSort` prop to `false`. - -```svelte - - -``` - -## HTML Forms - -Since there is a near endless number of possible values that a user can select, the slider does not render a hidden input element by default. - -You'll need to determine how you want to submit the value(s) of the slider with a form. - -Here's an example of how you might do that: - -```svelte - -
    - - - - - - - -``` - -## API Reference - -### Slider.Root - -The root slider component which contains the remaining slider components. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `union`- 'single' \| 'multiple' | The type of the slider. If set to `'multiple'`, the slider will allow multiple thumbs and the `value` will be an array of numbers.`Default: undefined` | -| `value` $bindable | `number` | The current value of the slider. If the `type` is set to `'multiple'`, this should be an array of numbers and will default to an empty array.`Default: 0` | -| `onValueChange` | `function`- (value: number) => void \| (value: number\[]) => void | A callback function called when the value state of the slider changes.`Default: undefined` | -| `onValueCommit` | `function`- (value: number) => void \| (value: number\[]) => void | A callback function called when the user finishes dragging the thumb and the value changes. This is different than the `onValueChange` callback because it waits until the user stops dragging before calling the callback, where the `onValueChange` callback is called immediately after the user starts dragging.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `max` | `number` | The maximum value of the slider.`Default: 100` | -| `min` | `number` | The minimum value of the slider.`Default: 0` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the slider.`Default: 'horizontal'` | -| `step` | `number` | The step value of the slider.`Default: 1` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `autoSort` | `boolean` | Whether to automatically sort the values in the array when moving thumbs past one another. This is only applicable to the `'multiple'` type.`Default: true` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- '' | The orientation of the slider. | -| `data-slider-root` | `''` | Present on the root element. | - -### Slider.Range - -The range of the slider. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-slider-range` | `''` | Present on the range elements. | - -### Slider.Thumb - -A thumb on the slider. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index` required | `number` | The index of the thumb in the array of thumbs provided by the `thumbs` `children` snippet prop.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the thumb is disabled.`Default: false` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-slider-thumb` | `''` | Present on the thumb elements. | - -### Slider.Tick - -A tick mark on the slider. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index` required | `number` | The index of the tick in the array of ticks provided by the `ticks` `children` snippet prop.`Default: undefined` | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-bounded` | `''` | Present when the tick is bounded. | -| `data-slider-tick` | `''` | Present on the tick elements. | - ----------------------------------------------------- - -# Switch Documentation - -A toggle control enabling users to switch between "on" and "off" states. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    - - - - Do not disturb -
    -``` - -## Overview - -The Switch component provides an intuitive and accessible toggle control, allowing users to switch between two states, typically "on" and "off". This component is commonly used for enabling or disabling features, toggling settings, or representing boolean values in forms. The Switch offers a more visual and interactive alternative to traditional checkboxes for binary choices. - -## Key Features - -- **Accessibility**: Built with WAI-ARIA guidelines in mind, ensuring keyboard navigation and screen reader support. -- **State Management**: Internally manages the on/off state, with options for controlled and uncontrolled usage. -- **Style-able**: Data attributes allow for smooth transitions between states and custom styles. -- **HTML Forms**: Can render a hidden input element for form submissions. ## Architecture - -The Switch component is composed of two main parts: - -- **Root**: The main container component that manages the state and behavior of the switch. -- **Thumb**: The "movable" part of the switch that indicates the current state. ## Structure - -Here's an overview of how the Switch component is structured in code: - -```svelte - - - - -``` - -## Reusable Components - -It's recommended to use the `Switch` primitives to create your own custom switch component that can be used throughout your application. - -In the example below, we're using the `Checkbox` and [`Label`](/docs/components/label) components to create a custom switch component. - -MySwitch.svelte - -```svelte - - - - -{labelText} -``` - -You can then use the `MySwitch` component in your application like so: - -```svelte - - -``` - -## Managing Checked State - -This section covers how to manage the `checked` state of the component. - -### Two-Way Binding - -Use `bind:checked` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Disabled State - -You can disable the switch by setting the `disabled` prop to `true`. - -```svelte - - -``` - -## HTML Forms - -If you pass the `name` prop to `Switch.Root`, a hidden input element will be rendered to submit the value of the switch to a form. - -By default, the input will be submitted with the default checkbox value of `'on'` if the switch is checked. - -```svelte - - -``` - -### Custom Input Value - -If you'd prefer to submit a different value, you can use the `value` prop to set the value of the hidden input. - -For example, if you wanted to submit a string value, you could do the following: - -```svelte - - - -``` - -### Required - -If you want to make the switch required, you can use the `required` prop. - -```svelte - - -``` - -This will apply the `required` attribute to the hidden input element, ensuring that proper form submission is enforced. - -## API Reference - -### Switch.Root - -The root switch component used to set and manage the state of the switch. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `checked` $bindable | `boolean` | Whether or not the switch is checked.`Default: false` | -| `onCheckedChange` | `function`- (checked: boolean) => void | A callback function called when the checked state of the switch changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `name` | `string` | The name of the hidden input element, used to identify the input in form submissions.`Default: undefined` | -| `required` | `boolean` | Whether or not the switch is required to be checked.`Default: false` | -| `value` | `string` | The value of the hidden input element to be used in form submissions when the switch is checked.`Default: undefined` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `''` | The switch's checked state. | -| `data-checked` | `''` | Present when the switch is checked. | -| `data-disabled` | `''` | Present when the switch is disabled. | -| `data-switch-root` | `''` | Present on the root element. | - -### Switch.Thumb - -The thumb on the switch used to indicate the switch's state. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLSpanElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `''` | The switch's checked state. | -| `data-checked` | `''` | Present when the switch is checked. | -| `data-switch-thumb` | `''` | Present on the thumb element. | - ----------------------------------------------------- - -# Tabs Documentation - -Organizes content into distinct sections, allowing users to switch between them. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    - - - Outbound - Inbound - - -
    -
    -

    - Prague -

    -

    06:05

    -
    -
    -

    3h 30m

    -
    -
    -

    - Malaga -

    -

    06:05

    -
    -
    -
    -
    - -
    -
    -
    -
    - -
    -
    -

    - Malaga -

    -

    07:25

    -
    -
    -

    3h 20m

    -
    -
    -

    - Prague -

    -

    10:45

    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -``` - -## Structure - -```svelte - - - - - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Orientation - -The `orientation` prop is used to determine the orientation of the `Tabs` component, which influences how keyboard navigation will work. - -When the `orientation` is set to `'horizontal'`, the `ArrowLeft` and `ArrowRight` keys will move the focus to the previous and next tab, respectively. When the `orientation` is set to `'vertical'`, the `ArrowUp` and `ArrowDown` keys will move the focus to the previous and next tab, respectively. - -```svelte - - - - -``` - -## Activation Mode - -By default, the `Tabs` component will automatically activate the tab associated with a trigger when that trigger is focused. This behavior can be disabled by setting the `activationMode` prop to `'manual'`. - -When set to `'manual'`, the user will need to activate the tab by pressing the trigger. - -```svelte - - -``` - -## API Reference - -### Tabs.Root - -The root tabs component which contains the other tab components. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` $bindable | `string` | The active tab value.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void | A callback function called when the active tab value changes.`Default: undefined` | -| `activationMode` | `enum`- 'automatic' \| 'manual' | How the activation of tabs should be handled. If set to `'automatic'`, the tab will be activated when the trigger is focused. If set to `'manual'`, the tab will be activated when the trigger is pressed.`Default: 'automatic'` | -| `disabled` | `boolean` | Whether or not the tabs are disabled.`Default: false` | -| `loop` | `boolean` | Whether or not the tabs should loop when navigating with the keyboard.`Default: true` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the tabs.`Default: horizontal` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- '' | The orientation of the tabs. | -| `data-tabs-root` | `''` | Present on the root element. | - -### Tabs.List - -The component containing the tab triggers. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- '' | The orientation of the tabs. | -| `data-tabs-list` | `''` | Present on the list element. | - -### Tabs.Trigger - -The trigger for a tab. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the tab this trigger represents.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the tab is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | The state of the tab trigger. | -| `data-value` | `''` | The value of the tab this trigger represents. | -| `data-orientation` | `enum`- '' | The orientation of the tabs. | -| `data-disabled` | `''` | Present when the tab trigger is disabled. | -| `data-tabs-trigger` | `''` | Present on the trigger elements. | - -### Tabs.Content - -The panel containing the contents of a tab. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the tab this content represents.`Default: undefined` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-tabs-content` | `''` | Present on the content elements. | - ----------------------------------------------------- - -# Toggle Group Documentation - -Groups multiple toggle controls, allowing users to enable one or multiple options. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - - - - - - - - - -``` - -## Structure - -```svelte - - - bold - italic - -``` - -## Single & Multiple - -The `ToggleGroup` component supports two `type` props, `'single'` and `'multiple'`. When the `type` is set to `'single'`, the `ToggleGroup` will only allow a single item to be selected at a time, and the type of the `value` prop will be a string. - -When the `type` is set to `'multiple'`, the `ToggleGroup` will allow multiple items to be selected at a time, and the type of the `value` prop will be an array of strings. - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## API Reference - -### ToggleGroup.Root - -The root component which contains the toggle group items. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `enum`- 'single' \| 'multiple' | The type of toggle group.`Default: undefined` | -| `value` $bindable | `union`- string \| string\[] | The value of the toggle group. If the `type` is `'multiple'`, this will be an array of strings, otherwise it will be a string.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void \| (value: string\[]) => void | A callback function called when the value of the toggle group changes. The type of the value is dependent on the type of the toggle group.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `loop` | `boolean` | Whether or not the toggle group should loop when navigating.`Default: true` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the toggle group.`Default: horizontal` | -| `rovingFocus` | `boolean` | Whether or not the toggle group should use roving focus when navigating.`Default: true` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- '' | The orientation of the toggle group. | -| `data-toggle-group-root` | `''` | Present on the root element. | - -### ToggleGroup.Item - -An individual toggle item within the group. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `string` | The value of the item.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | Whether the toggle item is in the on or off state. | -| `data-value` | `''` | The value of the toggle item. | -| `data-orientation` | `enum`- '' | The orientation of the toggle group. | -| `data-disabled` | `''` | Present when the toggle item is disabled. | -| `data-toggle-group-item` | `''` | Present on the toggle group item. | - ----------------------------------------------------- - -# Toggle Documentation - -A control element that switches between two states, providing a binary choice. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - -
    -
    - {code} -
    - - - -
    -``` - -## Structure - -```svelte - - -``` - -## Managing Pressed State - -This section covers how to manage the `pressed` state of the component. - -### Two-Way Binding - -Use `bind:pressed` for simple, automatic state synchronization: - -```svelte - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## API Reference - -### Toggle.Root - -The toggle button. - -| Property | Type | Description | -| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pressed` $bindable | `boolean` | Whether or not the toggle button is pressed.`Default: false` | -| `onPressedChange` | `function`- (pressed: boolean) => void | A callback function called when the pressed state of the toggle changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- '' | Whether the toggle is in the on or off state. | -| `data-disabled` | `''` | Present when the toggle is disabled. | -| `data-toggle-root` | `''` | Present on the root element. | - ----------------------------------------------------- - -# Toolbar Documentation - -Displays frequently used actions or tools in a compact, easily accessible bar. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - Ask AI - -
    -
    -``` - -## Structure - -```svelte - - - - - - - - -``` - -## Managing Value State - -This section covers how to manage the `value` state of the component. - -### Two-Way Binding - -Use `bind:value` for simple, automatic state synchronization: - -```svelte - - - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - - - -``` - -## API Reference - -### Toolbar.Root - -The root component which contains the toolbar. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loop` | `boolean` | Whether or not the toolbar should loop when navigating.`Default: true` | -| `orientation` | `enum`- 'horizontal' \| 'vertical' | The orientation of the toolbar.`Default: horizontal` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-orientation` | `enum`- 'vertical' \| 'horizontal' | The orientation of the component. | -| `data-toolbar-root` | `''` | Present on the root element. | - -### Toolbar.Button - -A button in the toolbar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the button is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| -------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-toolbar-button` | `''` | Present on the button element. | - -### Toolbar.Link - -A link in the toolbar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ref` $bindable | `HTMLAnchorElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `data-toolbar-link` | `''` | Present on the link element. | - -### Toolbar.Group - -A group of toggle items in the toolbar. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` required | `enum`- 'single' \| 'multiple' | The type of toggle group.`Default: 'single'` | -| `value` $bindable | `union`- string \| string\[] | The value of the toggle group. If the type is multiple, this will be an array of strings, otherwise it will be a string.`Default: undefined` | -| `onValueChange` | `function`- (value: string) => void \| (value: string\[]) => void | A callback function called when the value changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the switch is disabled.`Default: false` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-toolbar-group` | `''` | Present on the group element. | - -### Toolbar.GroupItem - -A toggle item in the toolbar toggle group. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` required | `string` | The value of the toolbar toggle group item. When the toolbar toggle group item is selected, toolbar the toggle group's value will be set to this value if in single mode, or this value will be pushed to the toggle group's array value if in multiple mode.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the item is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `data-state` | `enum`- '' | Whether the toolbar toggle item is in the on or off state. | -| `data-value` | `''` | The value of the toolbar toggle item. | -| `data-disabled` | `''` | Present when the toolbar toggle item is disabled. | -| `data-toolbar-item` | `''` | Present on the toolbar toggle item. | - ----------------------------------------------------- - -# Tooltip Documentation - -Provides additional information or context when users hover over or interact with an element. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -```svelte - - - - - - - -
    - Make some magic! -
    -
    -
    -
    -``` - -## Structure - -```svelte - - - - - - - - - - - -``` - -## Provider Component - -The `Tooltip.Provider` component is required to be an ancestor of the `Tooltip.Root` component. It provides shared state for the tooltip components used within it. You can set a single `delayDuration` or `disableHoverableContent` prop on the provider component to apply to all the tooltip components within it. - -```svelte - - - - - - - - - - - - - - - - - - - -``` - -It also ensures that only a single tooltip within the same provider can be open at a time. It's recommended to wrap your root layout content with the provider component, setting your sensible default props there. - -+layout.svelte - -```svelte - - - {@render children()} - -``` - -## Managing Open State - -This section covers how to manage the `open` state of the component. - -### Two-Way Binding - -Use `bind:open` for simple, automatic state synchronization: - -```svelte - - - - -``` - -### Fully Controlled - -Use a [Function Binding](https://svelte.dev/docs/svelte/bind#Function-bindings) for complete control over the state's reads and writes. - -```svelte - - - -``` - -## Mobile Tooltips - -Tooltips are *not* supported on mobile devices. The intent of a tooltip is to provide a "tip" about a "tool" before the user interacts with that tool (in most cases, a button). - -This is not possible on mobile devices, because there is no sense of hover on mobile. If a user were to press/touch a button with a tooltip, the action that button triggers would be taken before they were even able to see the tooltip, which renders it useless. - -If you are using a tooltip on a button without an action, you should consider using a [Popover](/docs/components/popover) instead. - -If you'd like to learn more about how we came to this decision, here are some useful resources: - -> -> -> The tooltip is not the appropriate role for the more information "i" icon, . A tooltip is directly associated with the owning element. The isn't 'described by' detailed information; the tool or control is. -> -> -> -> -> -> [MDN ARIA Tooltips](https://arc.net/l/quote/zfvjgalg) -> -> -> -> - -> -> -> Tooltips should only ever contain non-essential content. The best approach to writing tooltip content is to always assume it may never be read. -> -> -> -> -> -> [Tooltips in the time of WCAG 2.1](https://arc.net/l/quote/gdrkepxb) -> -> -> -> - -## Reusable Components - -It's recommended to use the `Tooltip` primitives to build your own custom tooltip component that can be used throughout your application. - -Below is an example of how you might create a reusable tooltip component that can be used throughout your application. Of course, this isn't the only way to do it, but it should give you a good idea of how to compose the primitives. - -MyTooltip.svelte - -```svelte - - - - - {@render trigger()} - - - - - {@render children?.()} - - - -``` - -You could then use the `MyTooltip` component in your application like so: - -+page.svelte - -```svelte - - alert("changed to bold!") }}> - {#snippet trigger()} - - {/snippet} - Change font to bold - -``` - -## Delay Duration - -You can change how long a user needs to hover over a trigger before the tooltip appears by setting the `delayDuration` prop on the `Tooltip.Root` or `Tooltip.Provider` component. - -```svelte - - -``` - -## Close on Trigger Click - -By default, the tooltip will close when the user clicks the trigger. If you want to disable this behavior, you can set the `disableCloseOnTriggerClick` prop to `true`. - -```svelte - - -``` - -## Hoverable Content - -By default, the tooltip will remain open when the user hovers over the content. If you instead want the tooltip to close as the user moves their mouse towards the content, you can set the `disableHoverableContent` prop to `true`. - -```svelte - - -``` - -## Non-Keyboard Focus - -If you want to prevent opening the tooltip when the user focuses the trigger without using the keyboard, you can set the `ignoreNonKeyboardFocus` prop to `true`. - -```svelte - - -``` - -## Svelte Transitions - -You can use the `forceMount` prop along with the `child` snippet to forcefully mount the `Tooltip.Content` component to use Svelte Transitions or another animation library that requires more control. - -```svelte - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    -
    -
    - {/if} - {/snippet} -
    -
    -``` - -Of course, this isn't the prettiest syntax, so it's recommended to create your own reusable content components that handles this logic if you intend to use this approach throughout your app. For more information on using transitions with Bits UI components, see the [Transitions](/docs/transitions) documentation. - -```svelte - - - - - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
    -
    -
    - Make some magic! -
    -
    -
    - {/if} - {/snippet} -
    -
    -
    -``` - -## Opt-out of Floating UI - -When you use the `Tooltip.Content` component, Bits UI uses [Floating UI](https://floating-ui.com/) to position the content relative to the trigger, similar to other popover-like components. - -You can opt-out of this behavior by instead using the `Tooltip.ContentStatic` component. This component does not use Floating UI and leaves positioning the content entirely up to you. - -```svelte - - Hello - - - -``` - -##### Note - -When using the `Tooltip.ContentStatic` component, the `Tooltip.Arrow` component will not be rendered relative to it as it is designed to be used with `Tooltip.Content`. - -## Custom Anchor - -By default, the `Tooltip.Content` is anchored to the `Tooltip.Trigger` component, which determines where the content is positioned. - -If you wish to instead anchor the content to a different element, you can pass either a selector `string` or an `HTMLElement` to the `customAnchor` prop of the `Tooltip.Content` component. - -```svelte - -
    - - - - - -``` - -```svelte - -
    -
    - Custom Anchor -
    - - - - - - -
    - Make some magic! -
    -
    -
    -
    -
    -``` - -## API Reference - -### Tooltip.Provider - -A provider component which contains shared state and logic for the tooltips within its subtree. - -| Property | Type | Description | -| ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `delayDuration` | `number` | The amount of time in milliseconds to delay opening the tooltip when hovering over the trigger.`Default: 700` | -| `disableHoverableContent` | `boolean` | Whether or not to disable the hoverable content. This is useful when the content contains interactive elements.`Default: false` | -| `disabled` | `boolean` | Whether or not the tooltip is disabled.`Default: false` | -| `disableCloseOnTriggerClick` | `boolean` | Whether or not to close the tooltip when pressing the escape key. This is useful when the content contains interactive elements.`Default: false` | -| `skipDelayDuration` | `number` | The amount of time in milliseconds to delay opening the tooltip when the user has used their mouse to hover over the trigger.`Default: 300` | -| `ignoreNonKeyboardFocus` | `boolean` | Whether or not to ignore the tooltip when the focus is not on the trigger. This is useful when the content contains interactive elements.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Tooltip.Root - -The root component containing the parts of the tooltip. Must be a descendant of a `Tooltip.Provider` component. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` $bindable | `boolean` | The open state of the tooltip component.`Default: false` | -| `onOpenChange` | `function`- (open: boolean) => void | A callback that fires when the open state changes.`Default: undefined` | -| `disabled` | `boolean` | Whether or not the tooltip is disabled.`Default: false` | -| `delayDuration` | `number` | The amount of time in milliseconds to delay opening the tooltip when hovering over the trigger.`Default: 700` | -| `disableHoverableContent` | `boolean` | Whether or not to disable the hoverable content. This is useful when the content contains interactive elements.`Default: false` | -| `disableCloseOnTriggerClick` | `boolean` | Whether or not to close the tooltip when pressing the escape key. This is useful when the content contains interactive elements.`Default: false` | -| `ignoreNonKeyboardFocus` | `boolean` | Whether or not to ignore the tooltip when the focus is not on the trigger. This is useful when the content contains interactive elements.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - -### Tooltip.Trigger - -A component which triggers the opening and closing of the tooltip on hover or focus. - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disabled` | `boolean` | Whether or not the tooltip trigger is disabled.`Default: false` | -| `ref` $bindable | `HTMLButtonElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'delayed-open' \| 'instant-open' \| 'closed' | Whether/how the tooltip is open or closed. When open, if there is a delay, the value will be 'delayed-open', otherwise it will be 'instant-open'. When closed, the value will be 'closed'. | -| `data-tooltip-trigger` | `''` | Present on the tooltip trigger element. | - -### Tooltip.Content - -The contents of the tooltip which are displayed when the tooltip is open. - -| Property | Type | Description | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `side` | `enum`- 'top' \| 'bottom' \| 'left' \| 'right' | The preferred side of the anchor to render the floating element against when open. Will be reversed when collisions occur.`Default: bottom` | -| `sideOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `align` | `enum`- 'start' \| 'center' \| 'end' | The preferred alignment of the anchor to render the floating element against when open. This may change when collisions occur.`Default: start` | -| `alignOffset` | `number` | The distance in pixels from the anchor to the floating element.`Default: 0` | -| `arrowPadding` | `number` | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `avoidCollisions` | `boolean` | When `true`, overrides the `side` and `align` options to prevent collisions with the boundary edges.`Default: true` | -| `collisionBoundary` | `union`- Element \| null | A boundary element or array of elements to check for collisions against.`Default: undefined` | -| `collisionPadding` | `union`- number \| Partial\<Record\<Side, number\>\> | The amount in pixels of virtual padding around the viewport edges to check for overflow which will cause a collision.`Default: 0` | -| `sticky` | `enum`- 'partial' \| 'always' | The sticky behavior on the align axis. `'partial'` will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst `'always'` will keep the content in the boundary regardless.`Default: partial` | -| `hideWhenDetached` | `boolean` | When `true`, hides the content when it is detached from the DOM. This is useful for when you want to hide the content when the user scrolls away.`Default: true` | -| `updatePositionStrategy` | `enum`- 'optimized' \| 'always' | The strategy to use when updating the position of the content. When `'optimized'` the content will only be repositioned when the trigger is in the viewport. When `'always'` the content will be repositioned whenever the position changes.`Default: optimized` | -| `strategy` | `enum`- 'fixed' \| 'absolute' | The positioning strategy to use for the floating element. When `'fixed'` the element will be positioned relative to the viewport. When `'absolute'` the element will be positioned relative to the nearest positioned ancestor.`Default: fixed` | -| `preventScroll` | `boolean` | When `true`, prevents the body from scrolling when the content is open. This is useful when you want to use the content as a modal.`Default: true` | -| `customAnchor` | `union`- string \| HTMLElement \| Measurable \| null | Use an element other than the trigger to anchor the content to. If provided, the content will be anchored to the provided element instead of the trigger.`Default: null` | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'delayed-open' \| 'instant-open' \| 'closed' | Whether/how the tooltip is open or closed. When open, if there is a delay, the value will be 'delayed-open', otherwise it will be 'instant-open'. When closed, the value will be 'closed'. | -| `data-tooltip-content` | `''` | Present on the tooltip content element. | - -| CSS Variable | Description | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--bits-tooltip-content-transform-origin` | The transform origin of the tooltip content element. | -| `--bits-tooltip-content-available-width` | The available width of the tooltip content element. | -| `--bits-tooltip-content-available-height` | The available height of the tooltip content element. | -| `--bits-tooltip-anchor-width` | The width of the tooltip trigger element. | -| `--bits-tooltip-anchor-height` | The height of the tooltip trigger element. | - -### Tooltip.ContentStatic - -The contents of the tooltip which are displayed when the tooltip is open. (Static/No Floating UI) - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `onInteractOutside` | `function`- (event: PointerEvent) => void | Callback fired when an outside interaction event occurs, which is a `pointerdown` event. You can call `event.preventDefault()` to prevent the default behavior of handling the outside interaction.`Default: undefined` | -| `onFocusOutside` | `function`- (event: FocusEvent) => void | Callback fired when focus leaves the dismissible layer. You can call `event.preventDefault()` to prevent the default behavior on focus leaving the layer.`Default: undefined` | -| `interactOutsideBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an interaction occurs outside of the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `onEscapeKeydown` | `function`- (event: KeyboardEvent) => void | Callback fired when an escape keydown event occurs in the floating content. You can call `event.preventDefault()` to prevent the default behavior of handling the escape keydown event.`Default: undefined` | -| `escapeKeydownBehavior` | `enum`- 'close' \| 'ignore' \| 'defer-otherwise-close' \| 'defer-otherwise-ignore' | The behavior to use when an escape keydown event occurs in the floating content. `'close'` will close the content immediately. `'ignore'` will prevent the content from closing. `'defer-otherwise-close'` will defer to the parent element if it exists, otherwise it will close the content. `'defer-otherwise-ignore'` will defer to the parent element if it exists, otherwise it will ignore the interaction.`Default: close` | -| `forceMount` | `boolean` | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content.`Default: false` | -| `dir` | `enum`- 'ltr' \| 'rtl' | The reading direction of the app.`Default: 'ltr'` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet`- Snippet | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-state` | `enum`- 'delayed-open' \| 'instant-open' \| 'closed' | Whether/how the tooltip is open or closed. When open, if there is a delay, the value will be 'delayed-open', otherwise it will be 'instant-open'. When closed, the value will be 'closed'. | -| `data-tooltip-content` | `''` | Present on the tooltip content element. | - -### Tooltip.Arrow - -An optional arrow element which points to the trigger when the tooltip is open. - -| Property | Type | Description | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `width` | `number` | The width of the arrow in pixels.`Default: 8` | -| `height` | `number` | The height of the arrow in pixels.`Default: 8` | -| `ref` $bindable | `HTMLDivElement` | The underlying DOM element being rendered. You can bind to this to get a reference to the element.`Default: undefined` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | -| `child` | `Snippet`- type SnippetProps = { props: Record\; }; | Use render delegation to render your own element. See [Child Snippet](/docs/child-snippet) docs for more information.`Default: undefined` | | Data Attribute | Value | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-arrow` | `''` | Present on the arrow element. | -| `data-tooltip-arrow` | `''` | Present on the arrow element. | -| `data-side` | `enum`- '' | The side of the tooltip that the arrow is on. | ### Tooltip.Portal - -When used, will render the tooltip content into the body or custom `to` element when open - -| Property | Type | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `to` | `union`- string \| HTMLElement \| null \| undefined | Where to render the content when it is open. Defaults to the body. Can be disabled by passing `null`.`Default: body` | -| `disabled` | `boolean` | Whether the portal is disabled or not. When disabled, the content will be rendered in its original DOM location.`Default: false` | -| `children` | `Snippet` | The children content to render.`Default: undefined` | - ----------------------------------------------------- - -# IsUsingKeyboard Documentation - -A utility to track whether the user is actively using the keyboard or not. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -## Overview `IsUsingKeyboard` is a utility component that tracks whether the user is actively using the keyboard or not. This component is used internally by Bits UI components to provide keyboard accessibility features. - -It provides global state that is shared across all instances of the class to prevent duplicate event listener registration. - -## Usage - -```svelte - -``` - ----------------------------------------------------- - -# mergeProps Documentation - -A utility function to merge props objects. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -## Overview `mergeProps` is a utility function designed to merge multiple props objects. It's particularly useful for composing components with different prop sets or extending the functionality of existing components. - -It is used internally by Bits UI components to merge the custom `restProps` you pass to a component with the props that Bits UI provides to the component. - -## Key Features - -- Merges multiple props objects -- Chains event handlers with cancellation support -- Combines class names -- Merges style objects and strings -- Chains non-event handler functions ## Detailed Behavior - -### Event Handlers - -Event handlers are chained in the order they're passed. If a handler calls `event.preventDefault()`, subsequent handlers in the chain are not executed. - -```ts -const props1 = { onclick: (e: MouseEvent) => console.log("First click") }; -const props2 = { onclick: (e: MouseEvent) => console.log("Second click") }; -const mergedProps = mergeProps(props1, props2); -mergedProps.onclick(new MouseEvent("click")); // Logs: "First click" then "Second click" -``` - -If `preventDefault()` is called: - -```ts -const props1 = { onclick: (e: MouseEvent) => console.log("First click") }; -const props2 = { - onclick: (e: MouseEvent) => { - console.log("Second click"); - e.preventDefault(); - }, -}; -const props3 = { onclick: (e: MouseEvent) => console.log("Third click") }; -const mergedProps = mergeProps(props1, props2, props3); -mergedProps.onclick(new MouseEvent("click")); // Logs: "First click" then "Second click" only -``` - -Since `props2` called `event.preventDefault()`, `props3`'s `onclick` handler will not be called. - -### Non-Event Handler Functions - -Non-event handler functions are also chained, but without the ability to prevent subsequent functions from executing: - -```ts -const props1 = { doSomething: () => console.log("Action 1") }; -const props2 = { doSomething: () => console.log("Action 2") }; -const mergedProps = mergeProps(props1, props2); -mergedProps.doSomething(); // Logs: "Action 1" then "Action 2" -``` - -### Classes - -Class names are merged using [`clsx`](https://www.npmjs.com/package/clsx): - -```ts -const props1 = { class: "text-lg font-bold" }; -const props2 = { class: ["bg-blue-500", "hover:bg-blue-600"] }; -const mergedProps = mergeProps(props1, props2); -console.log(mergedProps.class); // "text-lg font-bold bg-blue-500 hover:bg-blue-600" -``` - -### Styles - -Style objects and strings are merged, with later properties overriding earlier ones: - -```ts -const props1 = { style: { color: "red", fontSize: "16px" } }; -const props2 = { style: "background-color: blue; font-weight: bold;" }; -const mergedProps = mergeProps(props1, props2); -console.log(mergedProps.style); -// "color: red; font-size: 16px; background-color: blue; font-weight: bold;" -``` - -```ts -import { mergeProps } from "bits-ui"; -const props1 = { style: "--foo: red" }; -const props2 = { style: { "--foo": "green", color: "blue" } }; -const mergedProps = mergeProps(props1, props2); -console.log(mergedProps.style); // "--foo: green; color: blue;" -``` - ----------------------------------------------------- - -# Portal Documentation - -A component that renders its children in a portal, preventing layout issues in complex UI structures. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -## Overview - -The Portal component is a utility component that renders its children in a portal, preventing layout issues in complex UI structures. This component is used for the various Bits UI component that have a `Portal` sub-component. - -## Usage - -### Default behavior - -By default, the `Portal` component will render its children in the `body` element. - -```svelte - - -
    This content will be portalled to the body
    -
    -``` - -### Custom target - -You can use the `to` prop to specify a custom target element or selector to render the content to. - -```svelte - -
    -
    - -
    This content will be portalled to the #custom-target element
    -
    -
    -``` - -### Disable - -You can use the `disabled` prop to disable the portal behavior. - -```svelte - - -
    This content will not be portalled
    -
    -``` - ----------------------------------------------------- - -# useId Documentation - -A utility function to generate unique IDs. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `useId` function is a utility function that can be used to generate unique IDs. This function is used internally by all Bits UI components and is exposed for your convenience. - -## Usage - -```svelte - - - -``` - ----------------------------------------------------- - -# WithElementRef Documentation - -A type helper to enable the `ref` prop on a component. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `WithElementRef` type helper is a convenience type that enables you to follow the same [`ref`](/docs/ref) prop pattern as used by Bits UI components when crafting your own. - -```ts -type WithElementRef = T & { ref?: U | null }; -``` - -This type helper is used internally by Bits UI components to enable the `ref` prop on a component. - -## Usage Example - -CustomButton.svelte - -```svelte - - -``` - ----------------------------------------------------- - -# WithoutChild Documentation - -A type helper to exclude the child snippet prop from a component. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `WithoutChild` type helper is used to exclude the `child` snippet prop from a component. This is useful when you're building custom component wrappers that populate the `children` prop of a component and don't provide a way to pass a custom `child` snippet. - -To learn more about the `child` snippet prop, check out the [delegation](/docs/child-snippet) documentation. - -CustomAccordionHeader.svelte - -```svelte - - - - {@render children?.()} - - -``` - ----------------------------------------------------- - -# WithoutChildrenOrChild Documentation - -A type helper to exclude the child ad children snippet props from a component. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `WithoutChildrenOrChild` type helper is used to exclude the `child` and `children` props from a component. This is useful when you're building custom component wrappers that populate the `children` prop of a component and don't provide a way to pass a custom `children` or `child` snippet. - -To learn more about the `child` snippet prop, check out the [delegation](/docs/child-snippet) documentation. - -CustomAccordionTrigger.svelte - -```svelte - - - {title} - -``` - -Now, the `CustomAccordionTrigger` component won't expose `children` or `child` props to the user, but will expose the other root component props. - ----------------------------------------------------- - -# WithoutChildren Documentation - -A type helper to exclude the children snippet prop from a component. - -This is a documentation section that potentially contains examples, demos, and other useful information related to a specific part of Bits UI. When helping users with this documentation, you can ignore the classnames applied to the demos unless they are relevant to the user's issue. - -The `WithoutChildren` type helper is used to exclude the `children` snippet prop from a component. This is useful when you're building custom component wrappers that populate the `children` prop of a component. - -CustomAccordion.svelte - -```svelte - - - - - - - - -``` - -In the example above, we're using the `WithoutChildren` type helper to exclude the `children` snippet prop from the `Accordion.Root` component. This ensures our exposed props are consistent with what is being used internally. - ----------------------------------------------------- \ No newline at end of file diff --git a/llms/sveltekit-llms-small.txt b/llms/sveltekit-llms-small.txt deleted file mode 100644 index fb7e9c66..00000000 --- a/llms/sveltekit-llms-small.txt +++ /dev/null @@ -1,1345 +0,0 @@ -This is the abridged developer documentation for Svelte and SvelteKit. - -# Svelte documentation - -## Svelte - -You **MUST** use the Svelte 5 API unless explicitly tasked to write Svelte 4 syntax. If you don't know about the API yet, below is the most important information about it. Other syntax not explicitly listed like `{#if ...}` blocks stay the same, so you can reuse your Svelte 4 knowledge for these. - -- to mark something a state you use the `$state` rune, e.g. instead of `let count = 0` you do `let count = $state(0)` -- to mark something as a derivation you use the `$derived` rune, e.g. instead of `$: double = count * 2` you do `const double = $derived(count * 2)` -- to create a side effect you use the `$effect` rune, e.g. instead of `$: console.log(double)`you do`$effect(() => console.log(double))` -- to create component props you use the `$props` rune, e.g. instead of `export let foo = true; export let bar;` you do `let { foo = true, bar } = $props();` -- when listening to dom events do not use colons as part of the event name anymore, e.g. instead of ` - ``` -- Do **NOT** complicate state management by wrapping it in custom objects; instead, update reactive variables directly. - _In Svelte 4, you created state with let, e.g. `let count = 0;`, now use the $state rune, e.g. `let count = $state(0);`._ -- Arrays and objects become deeply reactive proxies. For example: - ```js - let todos = $state([{ done: false, text: 'add more todos' }]); - todos[0].done = !todos[0].done; - ``` -- Do **NOT** destructure reactive proxies (e.g., `let { done } = todos[0];`), as this breaks reactivity; instead, access properties directly. -- Use `$state` in class fields for reactive properties. For example: - ```js - class Todo { - done = $state(false); - text = $state(''); - reset = () => { - this.text = ''; - this.done = false; - }; - } - ``` - -### $state.raw - -- `$state.raw` creates shallow state where mutations are not tracked. For example: - -```js -let person = $state.raw({ name: 'Heraclitus', age: 49 }); -// Instead of mutating: -// person.age += 1; // NO effect -person = { name: 'Heraclitus', age: 50 }; // Correct way to update -``` - -- Do **NOT** attempt to mutate properties on raw state; instead, reassign the entire object to trigger updates. - -### $state.snapshot - -- `$state.snapshot` produces a plain object copy of reactive state. For example: - -```svelte - -``` - -- **ONLY** use this if you are told there's a problem with passing reactive proxies to external APIs. - -### Passing state into functions - -- Pass-by-Value Semantics: Use getter functions to ensure functions access the current value of reactive state. For example: - ```js - function add(getA, getB) { - return () => getA() + getB(); - } - let a = 1, - b = 2; - let total = add( - () => a, - () => b - ); - console.log(total()); - ``` -- Do **NOT** assume that passing a reactive state variable directly maintains live updates; instead, pass getter functions. - _In Svelte 4, you often used stores with subscribe methods; now prefer getter functions with `$state` / `$derived` instead._ - -### $derived - -- `$derived` computes reactive values based on dependencies. For example: - -```svelte - - -``` - -- Do **NOT** introduce side effects in derived expressions; instead, keep them pure. - _In Svelte 4 you used `$:` for this, e.g. `$: doubled = count * 2;`, now use the $derived rune instead, e.g `let doubled = $derived(count * 2);`._ - -#### $derived.by - -- Use `$derived.by` for multi-line or complex logic. For example: - -```svelte - -``` - -- Do **NOT** force complex logic into a single expression; instead, use `$derived.by` to keep code clear. - -#### Overriding derived values - -- You can reassign a derived value for features like optimistic UI. It will go back to the `$derived` value once an update in its dependencies happen. For example: - -```svelte - -``` - -- Do **NOT** try to override derived state via effects; instead, reassign directly when needed. - _In Svelte 4 you could use `$:` for that, e.g. `$: likes = post.likes; likes = 1`, now use the `$derived` instead, e.g. `let likes = $derived(post.likes); likes = 1;`._ - -### $effect - -- `$effect` executes functions when reactive state changes. For example: - -```svelte - -``` - -- Do **NOT** use `$effect` for state synchronization; instead, use it only for side effects like logging or DOM manipulation. - _In Svelte 4, you used reactive statements (`$:`) for similar tasks, .e.g `$: console.log(size)`; now use the `$effect` rune instead, e.g. `$effect(() => console.log(size))` ._ - -#### Understanding lifecycle (for $effect) - -- Effects run after the DOM updates and can return teardown functions. For example: - -```svelte - -``` - -- **Directive:** Do **NOT** ignore cleanup; instead, always return a teardown function when needed. - -#### $effect.pre - -- `$effect.pre` works like `$effect` with the only difference that it runs before the DOM updates. For example: - -```svelte - -``` - -- Do **NOT** use `$effect.pre` for standard post-update tasks; instead, reserve it for pre-DOM manipulation like autoscrolling. - -#### $effect.tracking - -- `$effect.tracking` indicates if code is running inside a reactive context. For example: - -```svelte - -``` - -- Do **NOT** misuse tracking information outside its intended debugging context; instead, use it to enhance reactive debugging. - _In Svelte 4, no equivalent existed; now this feature offers greater insight into reactivity._ - -#### $effect.root - -- `$effect.root` creates a non-tracked scope for nested effects with manual cleanup. For example: - -```svelte - -``` - -- Do **NOT** expect root effects to auto-cleanup; instead, manage their teardown manually. - _In Svelte 4, manual cleanup required explicit lifecycle hooks; now `$effect.root` centralizes this control._ - -### $props - -- Use `$props` to access component inputs. For example: - -```svelte - -

    This component is {adjective}

    -``` - -- Do **NOT** mutate props directly; instead, use callbacks or bindable props to communicate changes. - _In Svelte 4, props were declared with `export let foo`; now you use `$props` rune, e.g. `let { foo } = $props()`._ -- Declare fallback values via destructuring. For example: - -```js -let { adjective = 'happy' } = $props(); -``` - -- Rename props to avoid reserved keywords. For example: - -```js -let { super: trouper } = $props(); -``` - -- Use rest syntax to collect all remaining props. For example: - -```js -let { a, b, ...others } = $props(); -``` - -#### $props.id() - -- Generate a unique ID for the component instance. For example: - -```svelte - - - -``` - -- Do **NOT** manually generate or guess IDs; instead, rely on `$props.id()` for consistency. - -### $bindable - -- Mark props as bindable to allow two-way data flow. For example, in `FancyInput.svelte`: - -```svelte - - -``` - -- Do **NOT** overuse bindable props; instead, default to one-way data flow unless bi-directionality is truly needed. - _In Svelte 4, all props were implicitly bindable; in Svelte 5 `$bindable` makes this explicit._ - -### $host - -- Only available inside custom elements. Access the host element for custom event dispatching. For example: - -```svelte - - -``` - -- Do **NOT** use this unless you are explicitly tasked to create a custom element using Svelte components - -### {#snippet ...} - -- **Definition & Usage:** - Snippets allow you to define reusable chunks of markup with parameters inside your component. - _Example:_ - ```svelte - {#snippet figure(image)} -
    - {image.caption} -
    {image.caption}
    -
    - {/snippet} - ``` -- **Parameterization:** - Snippets accept multiple parameters with optional defaults and destructuring, but rest parameters are not allowed. - _Example with parameters:_ - ```svelte - {#snippet name(param1, param2)} - - {/snippet} - ``` - -### Snippet scope - -- **Lexical Visibility:** - Snippets can be declared anywhere and reference variables from their outer lexical scope, including script or block-level declarations. - _Example:_ - ```svelte - - {#snippet hello(name)} -

    hello {name}! {message}!

    - {/snippet} - {@render hello('alice')} - ``` -- **Scope Limitations:** - Snippets are only accessible within their lexical scope; siblings and child blocks share scope, but nested snippets cannot be rendered outside. - _Usage caution:_ Do **NOT** attempt to render a snippet outside its declared scope. - -### Passing snippets to components - -- **As Props:** - Within a template, snippets are first-class values that can be passed to components as props. - _Example:_ - ```svelte - - {#snippet header()} - fruit - qty - price - total - {/snippet} - {#snippet row(d)} - {d.name} - {d.qty} - {d.price} - {d.qty * d.price} - {/snippet} - - ``` -- **Slot-like Behavior:** - Snippets declared inside component tags become implicit props (akin to slots) for the component. - _Svelte 4 used slots for this, e.g. `

    hi {y}

    `; now use snippets instead, e.g. `{#snippet x(y)}

    hi {y}

    {/snippet}
    `._ -- **Content Fallback:** - Content not wrapped in a snippet declaration becomes the `children` snippet, rendering as fallback content. - _Example:_ - ```svelte - - - - - - ``` - -### Typing snippets - -- Snippets implement the `Snippet` interface, enabling strict type checking in TypeScript or JSDoc. - _Example:_ - -```svelte - -``` - -### {@render ...} - -- Use the {@render ...} tag to invoke and render a snippet, passing parameters as needed. - _Example:_ - ```svelte - {#snippet sum(a, b)} -

    {a} + {b} = {a + b}

    - {/snippet} - {@render sum(1, 2)} - ``` -- Do **NOT** call snippets without parentheses when parameters are required; instead, always invoke the snippet correctly. - _In Svelte 4, you used slots for this, e.g. ``; now use `{@render}` instead, e.g. `{@render sum(a,b)}`._ - -### - -- Use error boundary tags to prevent rendering errors in a section from crashing the whole app. - _Example:_ - - ```svelte - console.error(error)}> - - - ``` - -- **Failed Snippet for Fallback UI:** - Providing a `failed` snippet renders fallback content when an error occurs and supplies a `reset` function. - _Example:_ - - ```svelte - - - {#snippet failed(error, reset)} - - {/snippet} - - ``` - -### class - -- Svelte 5 allows objects for conditional class assignment using truthy keys. It closely follows the `clsx` syntax - _Example:_ - -```svelte - -
    Content
    -``` - - -# SvelteKit documentation - -## Project types - -SvelteKit supports all rendering modes: SPA, SSR, SSG, and you can mix them within one project. - -## Setup - -Scaffold a new SvelteKit project using `npx sv create` then follow the instructions. Do NOT use `npm create svelte` anymore, this command is deprecated. - -A SvelteKit project needs a `package.json` with the following contents at minimum: - -```json -{ - "devDependencies": { - "@sveltejs/adapter-auto": "^6.0.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } -} -``` - -Do NOT put any of the `devDependencies` listed above into `dependencies`, keep them all in `devDependencies`. - -It also needs a `vite.config.js` with the following at minimum: - -```js -import { defineConfig } from 'vite'; -import { sveltekit } from '@sveltejs/kit/vite'; - -export default defineConfig({ - plugins: [sveltekit()] -}); -``` - -It also needs a `svelte.config.js` with the following at minimum: - -```js -import adapter from '@sveltejs/adapter-auto'; - -export default { - kit: { - adapter: adapter() - } -}; -``` - -## Project structure - -- **`src/` directory:** - - `lib/` for shared code (`$lib`), `lib/server/` for server‑only modules (`$lib/server`), `params/` for matchers, `routes/` for your pages/components, plus `app.html`, `error.html`, `hooks.client.js`, `hooks.server.js`, and `service-worker.js`. - - Do **NOT** import server‑only code into client files -- **Top‑level assets & configs:** - - `static/` for public assets; `tests/` (if using Playwright); config files: `package.json` (with `@sveltejs/kit`, `svelte`, `vite` as devDeps), `svelte.config.js`, `tsconfig.json` (or `jsconfig.json`, extending `.svelte-kit/tsconfig.json`), and `vite.config.js`. - - Do **NOT** forget `"type": "module"` in `package.json` if using ESM. -- **Build artifacts:** - - `.svelte-kit/` is auto‑generated and safe to ignore or delete; it will be recreated on `dev`/`build`. - - Do **NOT** commit `.svelte-kit/` to version control. - -## Routing - -- **Filesystem router:** `src/routes` maps directories to URL paths: Everything with a `+page.svelte` file inside it becomes a visitable URL, e.g. `src/routes/hello/+page.svelte` becomes `/hello`. `[param]` folders define dynamic segments. Do NOT use other file system router conventions, e.g. `src/routes/hello.svelte` does NOT become available als URL `/hello` -- **Route files:** Prefix with `+`: all run server‑side; only non‑`+server` run client‑side; `+layout`/`+error` apply recursively. -- **Best practice:** Do **not** hard‑code routes in code; instead rely on the filesystem convention. - -### +page.svelte - -- Defines UI for a route, SSR on first load and CSR thereafter -- Do **not** fetch data inside the component; instead use a `+page.js` or `+page.server.js` `load` function; access its return value through `data` prop via `let { data } = $props()` (typed with `PageProps`). - -```svelte - -

    {data.title}

    -``` - -### +page.js - -- Load data for pages via `export function load({ params })` (typed `PageLoad`), return value is put into `data` prop in component -- Can export `prerender`, `ssr`, and `csr` consts here to influence how page is rendered. -- Do **not** include private logic (DB or env vars), can **not** export `actions` from here; if needed, use `+page.server.js`. - -```js -import type { PageLoad } from './$types'; - -export const load: PageLoad = () => { - return { - title: 'Hello world!', - }; -} -``` - -### +page.server.js - -- `export async function load(...)` (typed `PageServerLoad`) to access databases or private env; return serializable data. -- Can also export `actions` for `
    ` handling on the server. - -### +error.svelte - -- Add `+error.svelte` in a route folder to render an error page, can use `page.status` and `page.error.message` from `$app/state`. -- SvelteKit walks up routes to find the closest boundary; falls back to `src/error.html` if none. - -### +layout.svelte - -- Place persistent elements (nav, footer) and include `{@render children()}` to render page content. Example: - -```svelte - - -

    Some Content that is shared for all pages below this layout

    - -{@render children()} -``` - -- Create subdirectory `+layout.svelte` to scope UI to nested routes, inheriting parent layouts. -- Use layouts to avoid repeating common markup; do **not** duplicate UI in every `+page.svelte`. - -### +layout.js / +layout.server.js - -- In `+layout.js` or `+layout.server.js` export `load()` (typed `LayoutLoad`) to supply `data` to the layout and its children; set `prerender`, `ssr`, `csr`. -- Use `+layout.server.js` (typed `LayoutServerLoad`) for server-only things like DB or env access. -- Do **not** perform server‑only operations in `+layout.js`; use the server variant. - -```js -import type { LayoutLoad } from './$types'; - -export const load: LayoutLoad = () => { - return { - sections: [ - { slug: 'profile', title: 'Profile' }, - { slug: 'notifications', title: 'Notifications' } - ] - }; -} -``` - -### +server.js (Endpoints) - -- Export HTTP handlers (`GET`, `POST`, etc.) in `+server.js` under `src/routes`; receive `RequestEvent`, return `Response` or use `json()`, `error()`, `redirect()` (exported from `@sveltejs/kit`). -- export `fallback` to catch all other methods. - -```js -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = ({ url }) => { - return new Response('hello world'); -} -``` - -### $types - -- SvelteKit creates `$types.d.ts` with `PageProps`, `LayoutProps`, `RequestHandler`, `PageLoad`, etc., for type‑safe props and loaders. -- Use them inside `+page.svelte`/`+page.server.js`/`+page.js`/`+layout.svelte`/`+layout.server.js`/`+layout.js` by importing from `./$types` - -### Other files - -- Any non‑`+` files in route folders are ignored by the router, use this to your advantage to colocate utilities or components. -- For cross‑route imports, place modules under `src/lib` and import via `$lib`. - -## Loading data - -### Page data - -- `+page.js` exports a `load` (`PageLoad`) whose returned object is available in `+page.svelte` via `let { data } = $props()` (e.g. when you do `return { foo }` from `load` it is available within `let { data } = $props()` in `+page.svelte` as `data.foo`) -- Universal loads run on SSR and CSR; private or DB‑backed loads belong in `+page.server.js` (`PageServerLoad`) and must return devalue‑serializable data. - -Example: - -```js -// file: src/routes/foo/+page.js -export async function load({ fetch }) { - const result = await fetch('/data/from/somewhere').then((r) => r.json()); - return { result }; // return property "result" -} -``` - -```svelte - - -{data.result} -``` - -### Layout data - -- `+layout.js` or `+layout.server.js` exports a `load` (`LayoutLoad`/`LayoutServerLoad`) -- Layout data flows downward: child layouts and pages see parent data in their `data` prop. -- Data loading flow (interaction of load function and props) works the same as for `+page(.server).js/svelte` - -### page.data - -- The `page` object from `$app/state` gives access to all data from `load` functions via `page.data`, usable in any layout or page. -- Ideal for things like `{page.data.title}`. -- Types come from `App.PageData` -- earlier Svelte versions used `$app/stores` for the same concepts, do NOT use `$app/stores` anymore unless prompted to do so - -### Universal vs. server loads - -- Universal (`+*.js`) run on server first, then in browser; server (`+*.server.js`) always run server‑side and can use secrets, cookies, DB, etc. -- Both receive `params`, `route`, `url`, `fetch`, `setHeaders`, `parent`, `depends`; server loads additionally get `cookies`, `locals`, `platform`, `request`. -- Use server loads for private data or non‑serializable items; universal loads for public APIs or returning complex values (like constructors). - -### Load function arguments - -- `url` is a `URL` object (no `hash` server‑side); `route.id` shows the route pattern; `params` map path segments to values. -- Query parameters via `url.searchParams` trigger reruns when they change. -- Use these to branch logic and fetch appropriate data in `load`. - -## Making Fetch Requests - -Use the provided `fetch` function for enhanced features: - -```js -// src/routes/items/[id]/+page.js -export async function load({ fetch, params }) { - const res = await fetch(`/api/items/${params.id}`); - const item = await res.json(); - return { item }; -} -``` - -## Headers and Cookies - -Set response headers using `setHeaders`: - -```js -export async function load({ fetch, setHeaders }) { - const response = await fetch(url); - - setHeaders({ - age: response.headers.get('age'), - 'cache-control': response.headers.get('cache-control') - }); - - return response.json(); -} -``` - -Access cookies in server load functions using `cookies`: - -```js -export async function load({ cookies }) { - const sessionid = cookies.get('sessionid'); - return { - user: await db.getUser(sessionid) - }; -} -``` - -Do not set `set-cookie` via `setHeaders`; use `cookies.set()` instead. - -## Using Parent Data - -Access data from parent load functions: - -```js -export async function load({ parent }) { - const { a } = await parent(); - return { b: a + 1 }; -} -``` - -## Errors and Redirects - -Redirect users using `redirect`: - -```js -import { redirect } from '@sveltejs/kit'; - -export function load({ locals }) { - if (!locals.user) { - redirect(307, '/login'); - } -} -``` - -Throw expected errors using `error`: - -```js -import { error } from '@sveltejs/kit'; - -export function load({ locals }) { - if (!locals.user) { - error(401, 'not logged in'); - } -} -``` - -Unexpected exceptions trigger `handleError` hook and a 500 response. - -## Streaming with Promises - -Server load functions can stream promises as they resolve: - -```js -export async function load({ params }) { - return { - comments: loadComments(params.slug), - post: await loadPost(params.slug) - }; -} -``` - -```svelte -

    {data.post.title}

    -
    {@html data.post.content}
    - -{#await data.comments} - Loading comments... -{:then comments} - {#each comments as comment} -

    {comment.content}

    - {/each} -{:catch error} -

    error loading comments: {error.message}

    -{/await} -``` - -## Rerunning Load Functions - -Load functions rerun when: - -- Referenced params or URL properties change -- A parent load function reran and `await parent()` was called -- A dependency was invalidated with `invalidate(url)` or `invalidateAll()` - -Manually invalidate load functions: - -```js -// In load function -export async function load({ fetch, depends }) { - depends('app:random'); - // ... -} - -// In component -import { invalidate } from '$app/navigation'; -function rerunLoadFunction() { - invalidate('app:random'); -} -``` - -## Dependency Tracking - -Exclude from dependency tracking with `untrack`: - -```js -export async function load({ untrack, url }) { - if (untrack(() => url.pathname === '/')) { - return { message: 'Welcome!' }; - } -} -``` - -### Implications for authentication - -- Layout loads don’t automatically rerun on CSR; guards in `+layout.server.js` require child pages to await the parent. -- To avoid missed auth checks and waterfalls, use hooks like `handle` for global protection or per‑page server loads. - -### Using getRequestEvent - -- `getRequestEvent()` retrieves the current server `RequestEvent`, letting shared functions (e.g. `requireLogin()`) access `locals`, `url`, etc., without parameter passing. - -## Using forms - -### Form actions - -- A `+page.server.js` can export `export const actions: Actions = { default: async (event) => {…} }`; `` in `+page.svelte` posts to the default action without any JS. `+page.js` or `+layout.js` or `+layout.server.js` can NOT export `actions` -- Name multiple actions (`login`, `register`) in `actions`, invoke with `action="?/register"` or `button formaction="?/register"`; do NOT use `default` name in this case. -- Each action gets `{ request, cookies, params }`, uses `await request.formData()`, sets cookies or DB state, and returns an object that appears on the page as `form` (typed via `PageProps`). - -Example: Define a default action in `+page.server.js`: - -```js -// file: src/routes/login/+page.server.js -import type { Actions } from './$types'; - -export const actions: Actions = { - default: async (event) => { - // TODO log the user in - } -}; -``` - -Use it with a simple form: - -```svelte - - - - - - -``` - -### Validation errors - -- Return `fail(400, { field, error: true })` from an action to send back status and data; display via `form?.field` and repopulate inputs with `value={form?.field ?? ''}`. -- Use `fail` instead of throwing so the nearest `+error.svelte` isn’t invoked and the user can correct their input. -- `fail` payload must be JSON‑serializable. - -### Redirects - -- In an action, call `redirect(status, location)` to send a 3xx redirect; this throws and bypasses form re-render. -- Client-side, use `goto()` from `$app/navigation` for programmatic redirects. - -### Loading data after actions - -- After an action completes (unless redirected), SvelteKit reruns `load` functions and re‑renders the page, merging the action’s return value into `form`. -- The `handle` hook runs once before the action; if you modify cookies in your action, you must also update `event.locals` there to keep `load` in sync. -- Do NOT assume `locals` persists automatically; set `event.locals` inside your action when auth state changes. - -### Progressive enhancement - -- Apply `use:enhance` from `$app/forms` to `
    ` to intercept submissions, prevent full reloads, update `form`, `page.form`, `page.status`, reset the form, invalidate all data, handle redirects, render errors, and restore focus. Do NOT use onsubmit event for progressive enhancement -- To customize, provide a callback that runs before submit and returns a handler; use `update()` for default logic or `applyAction(result)` to apply form data without full invalidation. -- You can also write your own `onsubmit` listener using `fetch`, then `deserialize` the response and `applyAction`/`invalidateAll`; do NOT use `JSON.parse` for action responses. - -```svelte - - - - - -``` - -## Page options - -#### prerender - -- Set `export const prerender = true|false|'auto'` in page or layout modules; `true` generates static HTML, `false` skips, `'auto'` includes in SSR manifest. -- Applies to pages **and** `+server.js` routes (inherit parent flags); dynamic routes need `entries()` or `config.kit.prerender.entries` to tell the crawler which parameter values to use. -- Do NOT prerender pages that use form actions or rely on `url.searchParams` server‑side. - -#### entries - -- In a dynamic route’s `+page(.server).js` or `+server.js`, export `export function entries(): Array>` (can be async) to list parameter sets for prerendering. -- Overrides default crawling to ensure dynamic pages (e.g. `/blog/[slug]`) are generated. -- Do NOT forget to pair `entries()` with `export const prerender = true`. - -### ssr - -- `export const ssr = false` disables server-side rendering, sending only an HTML shell and turning the page into a client-only SPA. -- Use sparingly (e.g. when using browser‑only globals); do NOT set both `ssr` and `csr` to `false` or nothing will render. - -#### csr - -- `export const csr = false` prevents hydration, omits JS bundle, disables ` -
    - ``` - -### Imports from `$app/navigation` - -- **afterNavigate**: run code after every client‑side navigation. Needs to be called at component initialization - - ```js - import { afterNavigate } from '$app/navigation'; - afterNavigate(({ type, to }) => console.log('navigated via', type)); - ``` - -- **beforeNavigate**: intercept and optionally cancel upcoming navigations. Needs to be called at component initialization - - ```js - import { beforeNavigate } from '$app/navigation'; - beforeNavigate(({ cancel }) => { - if (!confirm('Leave?')) cancel(); - }); - ``` - -- **disableScrollHandling**: disable automatic scroll resetting after navigation - - ```js - import { disableScrollHandling } from '$app/navigation'; - disableScrollHandling(); - ``` - -- **goto**: programmatically navigate within the app - - ```svelte - - - ``` - -- **invalidate**: re‑run `load` functions that depend on a given URL or custom key - - ```js - import { invalidate } from '$app/navigation'; - await invalidate('/api/posts'); - ``` - -- **invalidateAll**: re‑run every `load` for the current page - - ```js - import { invalidateAll } from '$app/navigation'; - await invalidateAll(); - ``` - -- **onNavigate**: hook invoked immediately before client‑side navigations. Needs to be called at component initialization - - ```js - import { onNavigate } from '$app/navigation'; - onNavigate(({ to }) => console.log('about to go to', to.url)); - ``` - -- **preloadCode**: import route modules ahead of navigation (no data fetch) - - ```js - import { preloadCode } from '$app/navigation'; - await preloadCode('/about'); - ``` - -- **preloadData**: load both code and data for a route ahead of navigation - - ```js - import { preloadData } from '$app/navigation'; - const result = await preloadData('/posts/1'); - ``` - -- **pushState**: create a shallow‑routing history entry with custom state - - ```js - import { pushState } from '$app/navigation'; - pushState('', { modalOpen: true }); - ``` - -- **replaceState**: replace the current history entry with new custom state - - ```js - import { replaceState } from '$app/navigation'; - replaceState('', { modalOpen: false }); - ``` - -### Imports from `$app/paths` - -- **assets**: the absolute URL prefix for static assets (`config.kit.paths.assets`) - - ```js - import { assets } from '$app/paths'; - console.log(``); - ``` - -- **base**: the base path for your app (`config.kit.paths.base`) - - ```svelte - About Us - ``` - -- **resolveRoute**: interpolate a route ID with parameters to form a pathname - - ```js - import { resolveRoute } from '$app/paths'; - resolveRoute('/blog/[slug]/[...rest]', { - slug: 'hello', - rest: '2024/updates' - }); - // → "/blog/hello/2024/updates" - ``` - -### Imports from `$app/server` - -- **getRequestEvent** _(v2.20+)_: retrieve the current server `RequestEvent` - - ```js - import { getRequestEvent } from '$app/server'; - export function load() { - const event = getRequestEvent(); - console.log(event.url); - } - ``` - -- **read** _(v2.4+)_: read a static asset imported by Vite as a `Response` - - ```js - import { read } from '$app/server'; - import fileUrl from './data.txt'; - const res = read(fileUrl); - console.log(await res.text()); - ``` - -- **navigating**: a read‑only object describing any in‑flight navigation (or `null`) - - ```svelte - - ``` - -### Imports from `$app/state` - -- **page**: read‑only reactive info about the current page (`url`, `params`, `data`, etc.) - - ```svelte - - {path} - ``` - -- **updated**: reactive flag for new app versions; call `updated.check()` to poll immediately - - ```svelte - - ``` - -### Imports from `$env/dynamic/private` - -- **env (dynamic/private)**: runtime private env vars (`process.env…`), not exposed to client - - ```js - import { env } from '$env/dynamic/private'; - console.log(env.SECRET_API_KEY); - ``` - -### Imports from `$env/dynamic/public` - -- **env (dynamic/public)**: runtime public env vars (`PUBLIC_…`), safe for client use - - ```js - import { env } from '$env/dynamic/public'; - console.log(env.PUBLIC_BASE_URL); - ``` - -### Imports from `$env/static/private` - -- **$env/static/private**: compile‑time private env vars, dead‑code eliminated - - ```js - import { DATABASE_URL } from '$env/static/private'; - console.log(DATABASE_URL); - ``` - -### Imports from `$env/static/public` - -- **$env/static/public**: compile‑time public env vars (`PUBLIC_…`), safe on client - - ```js - import { PUBLIC_WS_ENDPOINT } from '$env/static/public'; - console.log(PUBLIC_WS_ENDPOINT); - ``` - -### `$lib` alias - -Alias for `src/lib` folder, e.g. - -```svelte - - -``` - -means that there's a component at `src/lib/Button.svelte`. \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index e69de29b..d22b5c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -0,0 +1,868 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "CropWatch", + "hello_world": "Hello, {name}!", + "common_name": "Name", + "common_description": "Description", + "common_group": "Group", + "common_created": "Created", + "common_actions": "Actions", + "common_all_short": "ALL", + "common_not_available": "N/A", + "action_back": "Back", + "action_back_to_dashboard": "Back to Dashboard", + "action_cancel": "Cancel", + "action_close": "Close", + "action_delete": "Delete", + "action_dismiss": "Dismiss", + "action_download": "Download", + "action_edit": "Edit", + "action_go_back": "Go Back", + "action_go_home": "Go Home", + "action_install": "Install", + "action_later": "Later", + "action_not_now": "Not now", + "action_remove": "Remove", + "action_save_changes": "Save Changes", + "action_saving": "Saving…", + "action_try_again": "Try again", + "action_view": "View", + "common_day_sunday": "Sunday", + "common_day_monday": "Monday", + "common_day_tuesday": "Tuesday", + "common_day_wednesday": "Wednesday", + "common_day_thursday": "Thursday", + "common_day_friday": "Friday", + "common_day_saturday": "Saturday", + "nav_dashboard": "Dashboard", + "nav_locations": "Locations", + "nav_rules": "Rules", + "nav_reports": "Reports", + "nav_profile": "Profile", + "nav_billing": "Billing", + "nav_settings": "Settings", + "nav_logout": "Logout", + "header_privacy_mode": "Privacy Mode", + "header_logout_error": "An error occurred while logging out. Please try again.", + "status_online": "Online", + "status_offline": "Offline", + "status_warning": "Warning", + "status_loading": "Loading", + "status_alerts": "Alerts", + "sidebar_all_groups": "All groups", + "sidebar_all_location_groups": "All location groups", + "sidebar_all_locations": "All locations", + "sidebar_search_devices_placeholder": "Search devices...", + "sidebar_dashboard_filters": "Dashboard Filters", + "sidebar_filter_devices_in_view": "Filter devices in view", + "sidebar_device_groups": "Device Groups", + "sidebar_location_groups": "Location Groups", + "sidebar_locations": "Locations", + "sidebar_current_session_expires": "Current Session Expires", + "overview_online_count": "Online {count}", + "overview_offline_count": "Offline {count}", + "overview_alert_count": "Alerts {count}", + "overview_status_mix": "Status mix", + "overview_total": "Total {count}", + "overview_top_groups": "Top groups", + "overview_in_view": "In view", + "overview_active_alert_list": "Active Alert List", + "overview_reported_time": "Reported Time", + "overview_ungrouped": "Ungrouped", + "dashboard_page_title": "CropWatch Dashboard", + "dashboard_devices_in_view": "devices in view", + "dashboard_with_active_alerts": "with active alerts", + "dashboard_total_online": "Total Online: {count}", + "dashboard_total_offline": "Total Offline: {count}", + "dashboard_table_view": "Table", + "dashboard_sensor_cards_view": "Sensor Cards", + "dashboard_notifications_alt": "Notifications", + "dashboard_loading_view": "Loading dashboard view...", + "dashboard_loading_devices": "Loading devices…", + "error_bad_request_title": "Bad Request", + "error_bad_request_description": "The server could not understand your request. Please check the URL and try again.", + "error_unauthorized_title": "Unauthorized", + "error_unauthorized_description": "You need to be logged in to access this page.", + "error_forbidden_title": "Forbidden", + "error_forbidden_description": "You do not have permission to access this resource.", + "error_not_found_title": "Page Not Found", + "error_not_found_description": "The page you are looking for does not exist or has been moved.", + "error_timeout_title": "Request Timeout", + "error_timeout_description": "The server timed out waiting for your request. Please try again.", + "error_too_many_requests_title": "Too Many Requests", + "error_too_many_requests_description": "You have made too many requests. Please wait a moment and try again.", + "error_internal_server_title": "Internal Server Error", + "error_internal_server_description": "Something went wrong on our end. Please try again later.", + "error_bad_gateway_title": "Bad Gateway", + "error_bad_gateway_description": "The server received an invalid response. Please try again later.", + "error_service_unavailable_title": "Service Unavailable", + "error_service_unavailable_description": "The service is temporarily unavailable. Please try again later.", + "error_gateway_timeout_title": "Gateway Timeout", + "error_gateway_timeout_description": "The server took too long to respond. Please try again later.", + "error_unexpected_title": "Unexpected Error", + "error_unknown": "An unknown error occurred.", + "error_reset_and_login": "Reset & Sign In", + "offline_page_title": "CropWatch Offline", + "offline_page_description": "CropWatch is offline. Reconnect to load live device data, reports, and settings.", + "offline_eyebrow": "Offline", + "offline_heading": "CropWatch needs a connection for live data.", + "offline_body": "The app shell is installed and ready, but dashboards, reports, and settings still depend on reaching the network.", + "pwa_update_ready": "Update ready", + "pwa_update_title": "Reload into the latest CropWatch build.", + "pwa_update_body": "The new app shell has already downloaded and is ready to activate.", + "pwa_update_now": "Update now", + "pwa_install_app": "Install app", + "pwa_install_title": "Put CropWatch on your device like a native app.", + "pwa_install_body": "Launch faster from your home screen, taskbar, or desktop and keep the app shell offline-ready.", + "pwa_install_iphone": "Install on iPhone", + "pwa_install_iphone_title": "Add CropWatch to your home screen from Safari.", + "pwa_install_iphone_body": "Open Safari's Share menu, then choose Add to Home Screen for the full-screen app experience.", + "auth_email_label": "EMAIL", + "auth_email_label_required": "EMAIL *", + "auth_email_placeholder": "you@example.com", + "auth_password_label": "PASSWORD", + "auth_password_label_required": "PASSWORD *", + "auth_password_placeholder": "••••••••", + "auth_sign_in": "Sign in", + "auth_signing_in": "Signing in...", + "auth_sign_in_instead": "Sign in instead", + "auth_sign_in_existing_account": "sign in with an existing account", + "auth_login_page_title": "Login - CropWatch", + "auth_login_heading": "Welcome to CropWatch!", + "auth_login_subtitle": "Sign in to your account for your latest updates", + "auth_login_reason_auth_required": "Please sign in to continue.", + "auth_login_reason_expired": "Your session expired. Sign in again to continue.", + "auth_login_reason_signed_out": "You signed out successfully.", + "auth_login_reason_password_reset": "Use your updated password to sign in again.", + "auth_login_reason_error_recovery": "An error occurred. Your saved data has been cleared. Please sign in again.", + "auth_login_success_redirecting": "Login successful! Redirecting...", + "auth_login_failed": "Unable to sign in right now. Please try again.", + "auth_invalid_credentials": "Invalid email or password.", + "auth_invalid_form_submission": "Invalid form submission.", + "auth_security_copy": "Protected by reCAPTCHA and CropWatch Security", + "auth_security_refresh_error": "Unable to verify site security. Please refresh the page and try again.", + "auth_security_try_again": "Security verification failed. Please try again.", + "auth_loading_site_security": "Loading Site Security...", + "auth_create_account": "Create Account", + "auth_create_account_page_title": "Create Account - CropWatch", + "auth_creating_account": "Creating account...", + "auth_or_prefix": "or ", + "auth_first_name_label_required": "FIRST NAME *", + "auth_first_name_placeholder": "John", + "auth_last_name_label_required": "LAST NAME *", + "auth_last_name_placeholder": "Doe", + "auth_password_requirements": "Password requirements", + "auth_password_requirement_length": "At least 6 characters", + "auth_password_requirement_lowercase": "Lowercase letter (a-z)", + "auth_password_requirement_uppercase": "Uppercase letter (A-Z)", + "auth_password_requirement_number": "Number (0-9)", + "auth_password_requirement_symbol": "Symbol (!@#$ etc.)", + "auth_confirm_password_label_required": "CONFIRM PASSWORD *", + "auth_passwords_match": "✓ Passwords match", + "auth_passwords_do_not_match": "Passwords do not match", + "auth_passwords_do_not_match_plain": "Passwords do not match.", + "auth_company_name_label_required": "COMPANY NAME *", + "auth_company_name_placeholder": "Acme Corp", + "auth_required_agreements_label": "REQUIRED AGREEMENTS *", + "auth_agree_to": "I agree to the ", + "auth_terms_of_service": "Terms of Service", + "auth_privacy_policy": "Privacy Policy", + "auth_cookie_policy": "Cookie Policy", + "auth_all_three_required": "All three agreements are required to register.", + "auth_all_fields_required": "All fields are required.", + "auth_must_agree_all_policies": "You must agree to all required policies to register.", + "auth_account_exists": "An account with this email already exists.", + "auth_registration_failed": "Unable to create account right now. Please try again.", + "auth_not_authenticated": "Not authenticated.", + "auth_check_email_page_title": "Check Your Email - CropWatch", + "auth_check_email_heading": "Check Your Email", + "auth_check_email_account_created": "Your account has been created!", + "auth_check_email_activation_link_prefix": "We've sent an ", + "auth_check_email_activation_link_highlight": "activation link", + "auth_check_email_activation_link_suffix": " to the email address you provided.", + "auth_check_email_open_inbox_prefix": "Open your inbox, find the email from CropWatch, and ", + "auth_check_email_open_inbox_highlight": "click the link inside", + "auth_check_email_open_inbox_suffix": " to activate your account.", + "auth_check_email_missing_heading": "Don't see the email?", + "auth_check_email_tip_spam": "Check your spam or junk folder", + "auth_check_email_tip_address": "Make sure you entered the correct email address", + "auth_check_email_tip_delivery": "Allow a few minutes for delivery", + "auth_check_email_you": "YOU", + "auth_check_email_check": "CHECK", + "auth_check_email_email": "EMAIL", + "auth_check_email_spam": "SPAM", + "auth_check_email_next": "Next", + "auth_check_email_cant_find_it": "Can't find it???", + "auth_check_email_not_there": "Not there???", + "auth_check_email_contact_support": "Please contact support", + "auth_go_to_sign_in": "Go to Sign In", + "auth_forgot_password": "Forgot your password?", + "auth_forgot_password_page_title": "Forgot Password - CropWatch", + "auth_forgot_password_subtitle": "Enter your account email and we will send you a reset link.", + "auth_forgot_password_sent_body": "If an account exists with that email, we've sent a password reset link. Please check your inbox and spam folder.", + "auth_back_to_login": "Back to Login", + "auth_send_reset_link": "Send Reset Link", + "auth_sending": "Sending…", + "auth_enter_email": "Please enter your email address.", + "locations_all_title": "All Locations", + "locations_all_subtitle": "Aggregated from current device telemetry", + "locations_add_location": "Add Location", + "locations_create_title": "Create Location", + "locations_create_subtitle": "Add a new location to your account", + "locations_name_placeholder": "e.g. Greenhouse A", + "locations_optional_description": "Optional description", + "locations_optional_group_name": "Optional group name", + "locations_latitude_optional": "Latitude (optional)", + "locations_latitude_placeholder": "e.g. 35.6762", + "locations_longitude_optional": "Longitude (optional)", + "locations_longitude_placeholder": "e.g. 139.6503", + "locations_create_submit": "Create Location", + "locations_create_failed": "Failed to create location.", + "locations_latitude_must_be_number": "Latitude must be a number.", + "locations_longitude_must_be_number": "Longitude must be a number.", + "locations_missing_location_id": "Location was created but the API response did not include location_id.", + "locations_current_location": "Current Location", + "locations_location_with_id": "Location {id}", + "locations_location_title": "Location: {name}", + "locations_devices_for_location": "Devices returned for this location", + "locations_settings_page_title": "Location Settings - {name}", + "locations_back_to_location": "Back To Location", + "locations_settings_title": "Location Settings", + "locations_settings_subtitle": "Manage your location settings", + "locations_location_name": "Location Name", + "locations_enter_location_name": "Enter location name", + "locations_enter_group_name": "Enter group name", + "locations_update_location_name": "Update Location Name", + "locations_update_name_requires_login": "You must be logged in to update location name.", + "locations_invalid_location_id": "Invalid location id.", + "locations_name_cannot_be_empty": "Location name cannot be empty.", + "locations_name_updated": "Location name updated.", + "locations_update_name_failed": "Unable to update location name.", + "locations_permission_denied": "You do not have permission to access this location settings page.", + "locations_add_user_permissions_title": "Add new user to {name} with permissions", + "locations_add_user_permissions_subtitle": "Create a permission for a user on this location", + "locations_permission_level": "Permission Level", + "locations_apply_to_all_devices": "Apply to all devices in this location", + "locations_add_permission": "Add Permission", + "locations_manage_permissions_requires_login": "You must be logged in to manage location permissions.", + "locations_permission_created": "Location permission created.", + "locations_permission_create_failed": "Unable to create location permission.", + "locations_select_permission_to_edit": "Select a permission to edit.", + "locations_user_id_required": "User ID is required.", + "locations_missing_admin_user_id": "Missing admin user id in current session.", + "locations_permission_updated": "Location permission updated.", + "locations_permission_update_failed": "Unable to update location permission.", + "locations_choose_permission_level": "Choose a permission level…", + "locations_permission_level_required": "Permission level is required.", + "locations_user_permission_level_updated": "User permission level updated.", + "locations_user_permission_level_update_failed": "Unable to update user permission level.", + "locations_permission_id_required": "permission_id is required.", + "locations_permission_removed": "Location permission removed.", + "locations_permission_remove_failed": "Unable to remove location permission.", + "locations_confirm_delete_permission_title": "Remove Permission", + "locations_confirm_delete_permission_body": "Are you sure you want to remove permission access for {email}?", + "locations_delete_permission_failed": "Failed to delete permission.", + "devices_device": "Device", + "devices_add_device": "Add Device", + "devices_device_name": "Device Name", + "devices_unnamed_device": "Unnamed Device", + "rules_page_title": "Rules - CropWatch", + "rules_configured_rules": "Configured Rules", + "rules_create_new_rule": "Create New Rule", + "rules_view_rule": "View Rule", + "rules_edit_rule": "Edit Rule", + "rules_delete_rule": "Delete Rule", + "rules_delete_confirmation": "Are you sure you want to delete the rule \"{name}\"? This action cannot be undone.", + "rules_delete_failed": "Unable to delete this rule.", + "rules_rule_name": "Rule Name", + "rules_rule_name_placeholder": "e.g. High Temperature Alert", + "rules_notification_type": "Notification Type", + "rules_send_using": "Send Using", + "rules_recipient": "Recipient", + "rules_recipient_placeholder": "alert@example.com", + "rules_step_1_title": "1. Rule Details", + "rules_step_1_subtitle": "Give your rule a name and choose how to be notified", + "rules_edit_step_1_subtitle": "Update the rule name and notification settings", + "rules_step_2_title": "2. Select Device", + "rules_step_2_subtitle": "Choose which device this rule monitors", + "rules_no_devices_available": "No devices available. Please add a device first.", + "rules_select_device_placeholder": "Select a device...", + "rules_device_preselected": "Device was pre-selected from the query parameter.", + "rules_step_3_title": "3. Alert Criteria", + "rules_step_3_subtitle": "Define when this rule should trigger — add one or more conditions", + "rules_edit_step_3_subtitle": "Update the conditions that trigger this rule", + "rules_condition_number": "Condition {count}", + "rules_data_field": "Data Field", + "rules_operator": "Operator", + "rules_trigger_value": "Trigger Value", + "rules_trigger_value_placeholder": "e.g. 30", + "rules_reset_value": "Reset Value", + "rules_reset_value_placeholder": "Value to reset the trigger", + "rules_reset_value_optional_placeholder": "Value to reset the trigger (optional)", + "rules_reset_value_help": "When the sensor reading returns to this value, the rule resets and can trigger again.", + "rules_add_another_condition": "+ Add Another Condition", + "rules_step_4_title": "4. Review & Create", + "rules_step_4_subtitle": "Check your rule settings before saving", + "rules_step_4_review_save_title": "4. Review & Save", + "rules_step_4_review_save_subtitle": "Review your changes before saving", + "rules_rule_summary": "Rule Summary", + "rules_notify_via": "Notify via", + "rules_conditions": "Conditions", + "rules_trigger_count": "Trigger count", + "rules_complete_required_fields": "Complete all required fields above to see a preview of your rule.", + "rules_create_rule": "Create Rule", + "rules_creating": "Creating…", + "rules_created_success": "Rule \"{name}\" created successfully!", + "rules_create_failed": "Failed to create rule. Please try again.", + "rules_updated_success": "Rule \"{name}\" updated successfully!", + "rules_update_failed": "Failed to update rule. Please try again.", + "rules_invalid_rule_id": "Invalid rule ID", + "rules_rule_not_found": "Rule not found", + "reports_page_title": "Reports - CropWatch", + "reports_weekly_reports": "Weekly Reports", + "reports_for_device": "For Device", + "reports_unknown_location": "Unknown Location", + "reports_unknown_device": "Unknown Device", + "reports_delete_report": "Delete Report", + "reports_confirm_delete_title": "Confirm Report Deletion", + "reports_confirm_delete_body": "Are you sure you want to delete the report \"{name}\"? This action cannot be undone.", + "reports_delete_failed": "Unable to delete this report right now.", + "reports_delete_missing_id": "Unable to delete this report because the report ID is missing.", + "reports_deleted_named": "Deleted report \"{name}\".", + "reports_deleted_successfully": "Report deleted successfully.", + "reports_history": "History", + "reports_history_title": "Report History", + "reports_history_load_failed": "Unable to load report history right now.", + "reports_download_failed": "Unable to download this report right now.", + "reports_download_missing_signed_url": "Unable to download this report because the signed URL was missing.", + "reports_problem_reports": "Problem Reports", + "reports_problem_reports_tooltip": "This report is under development. Please check back later.", + "reports_problems_only_report": "Problems Only Report", + "reports_created_at": "Created At", + "reports_edit_page_title": "Edit Report", + "reports_create_page_title": "New Report", + "reports_create_page_subtitle": "Choose the device, delivery schedule, alerts, and recipients for this report.", + "reports_create_intro_eyebrow": "Reports", + "reports_create_stat_schedules": "Schedules", + "reports_create_stat_active": "Active", + "reports_create_stat_alerts": "Alerts", + "reports_create_stat_recipients": "Recipients", + "reports_create_invalid_toast": "Please complete the required fields before saving.", + "reports_create_success_toast": "Report created.", + "reports_create_validation_title": "Please check these fields", + "reports_create_validation_copy": "Update the items below and try again.", + "reports_create_basics_title": "Report details", + "reports_create_basics_subtitle": "Choose a name and device for this report.", + "reports_create_name_placeholder": "Weekly operations summary", + "reports_create_device_placeholder": "Choose a device", + "reports_create_field_id": "Internal ID", + "reports_create_field_report_id": "Report ID", + "reports_create_field_user_id": "User ID", + "reports_create_field_schedule_id": "Schedule ID", + "reports_create_resolved_device": "Selected device", + "reports_create_manual_entry": "No device selected", + "reports_create_resolved_device_hint": "Choose a device for this report.", + "reports_create_no_devices_loaded": "No devices are available right now.", + "reports_create_advanced_report_metadata": "System fields", + "reports_create_advanced_report_metadata_hint": "Most teams can leave these blank.", + "reports_create_schedules_title": "Delivery schedule", + "reports_create_schedules_subtitle": "Choose when this report should be sent.", + "reports_create_schedules_copy": "Add one or more delivery schedules for this report.", + "reports_create_add_schedule": "Add schedule", + "reports_create_empty_schedules": "No schedules yet. Add one if this report should be sent automatically.", + "reports_create_schedule_heading": "Schedule {index}", + "reports_create_schedule_copy": "Choose how often this report should be sent.", + "reports_create_schedule_active_label": "Enabled", + "reports_create_schedule_active_description": "Turn this delivery schedule on.", + "reports_create_schedule_week_label": "Weekly", + "reports_create_schedule_week_description": "Send this report each week.", + "reports_create_schedule_month_label": "Month-end", + "reports_create_schedule_month_description": "Send this report at the end of the month.", + "reports_create_schedule_child_device_label": "Device ID", + "reports_create_schedule_child_device_placeholder": "Use the main report device", + "reports_create_schedule_user_label": "User ID", + "reports_create_schedule_user_placeholder": "Leave blank unless needed", + "reports_create_advanced_schedule_metadata": "System fields", + "reports_create_alerts_title": "Alerts", + "reports_create_alerts_subtitle": "Choose which alert checks belong in this report.", + "reports_create_alerts_copy": "Add alert conditions if this report should focus on specific changes.", + "reports_create_add_alert": "Add alert", + "reports_create_empty_alerts": "No alerts added. Leave this blank for a general report.", + "reports_create_alert_heading": "Alert {index}", + "reports_create_alert_copy": "Choose what should trigger this alert.", + "reports_create_alert_name_placeholder": "High temperature", + "reports_create_alert_metric_key": "Measure", + "reports_create_alert_operator": "Condition", + "reports_create_alert_minimum": "Minimum", + "reports_create_alert_maximum": "Maximum", + "reports_create_alert_value": "Threshold", + "reports_create_alert_hex_color": "Highlight color", + "reports_create_alert_points_unit_label": "Unit", + "reports_create_alert_points_center_label": "Center", + "reports_create_alert_points_empty_title": "No alerts yet.", + "reports_create_alert_points_empty_description": "Add one to start mapping values onto the number line.", + "reports_create_alert_points_invalid_number": "Enter a valid number.", + "reports_create_alert_points_required_field": "{label} is required.", + "reports_create_alert_points_label_with_unit": "{label} ({unit})", + "reports_create_alert_points_invalid_preview_single": "1 alert still needs complete values before it can be drawn.", + "reports_create_alert_points_invalid_preview_multiple": "{count} alerts still need complete values before they can be drawn.", + "reports_create_alert_points_overlap_preview_single": "1 alert overlaps another alert. Adjust the values so each alert covers a unique part of the number line.", + "reports_create_alert_points_overlap_preview_multiple": "{count} alerts overlap other alerts. Adjust the values so each alert covers a unique part of the number line.", + "reports_create_alert_points_equal_bounds_warning": "Minimum and maximum are the same. Use \"Equals\" instead for clarity.", + "reports_create_alert_points_condition_equals": "Equals (=)", + "reports_create_alert_points_condition_range": "Range", + "reports_create_alert_points_condition_less_than": "Less than (<)", + "reports_create_alert_points_condition_less_than_or_equal": "Less than or equal (<=)", + "reports_create_alert_points_condition_greater_than": "Greater than (>)", + "reports_create_alert_points_condition_greater_than_or_equal": "Greater than or equal (>=)", + "reports_create_alert_points_waiting_for_value": "Waiting for a value.", + "reports_create_alert_points_waiting_for_threshold": "Waiting for a threshold.", + "reports_create_alert_points_range_missing_bounds": "Range requires both minimum and maximum.", + "reports_create_alert_points_description_equals": "Equals {value} {unit}", + "reports_create_alert_points_description_range": "Range {min} to {max} {unit}", + "reports_create_alert_points_description_less_than": "< {value} {unit}", + "reports_create_alert_points_description_less_than_or_equal": "<= {value} {unit}", + "reports_create_alert_points_description_greater_than": "> {value} {unit}", + "reports_create_alert_points_description_greater_than_or_equal": ">= {value} {unit}", + "reports_create_alert_points_overlap_error": "Overlaps with {labels}. Each alert must cover a unique part of the number line.", + "reports_create_alert_user_label": "User ID (optional)", + "reports_create_alert_user_placeholder": "Leave blank unless needed", + "reports_create_advanced_alert_metadata": "System fields", + "reports_create_recipients_title": "Recipients", + "reports_create_recipients_subtitle": "Choose who should receive this report.", + "reports_create_recipients_copy": "Add the people or inboxes that should receive this report.", + "reports_create_add_recipient": "Add recipient", + "reports_create_empty_recipients": "No recipients added yet.", + "reports_create_recipient_heading": "Recipient {index}", + "reports_create_recipient_copy": "Add a name and email address for each recipient.", + "reports_create_communication_method": "Send by", + "reports_create_email_label": "Email address", + "reports_create_email_placeholder": "name@company.com", + "reports_create_recipient_name_placeholder": "Operations team", + "reports_create_recipient_user_label": "User ID (optional)", + "reports_create_recipient_user_placeholder": "Leave blank unless needed", + "reports_create_resolved_method": "Delivery method", + "reports_create_no_destination": "No destination set yet.", + "reports_create_advanced_recipient_metadata": "System fields", + "reports_create_preview_title": "Review", + "reports_create_preview_subtitle": "Check your report before saving.", + "reports_create_preview_target": "Device", + "reports_create_preview_manual_target": "Selected device", + "reports_create_preview_missing_dev_eui": "No device selected", + "reports_create_preview_valid": "Ready to save", + "reports_create_preview_invalid": "Needs attention", + "reports_create_preview_ready": "Ready", + "reports_create_preview_issue_single": "1 issue", + "reports_create_preview_issue_multiple": "{count} issues", + "reports_create_submit": "Save report", + "reports_create_new_report": "New Report", + "reports_create_payload_unreadable": "We couldn't read the form. Please try again.", + "reports_create_failed": "We couldn't create the report right now.", + "reports_create_operator_gt": "is above", + "reports_create_operator_gte": "is at or above", + "reports_create_operator_lt": "is below", + "reports_create_operator_lte": "is at or below", + "reports_create_operator_eq": "is exactly", + "reports_create_operator_range": "is between", + "reports_create_method_email": "Email", + "reports_create_method_sms": "Text message", + "reports_create_method_discord": "Discord", + "reports_schedule_card_title": "Data Processing Schedules", + "reports_schedule_card_subtitle": "Configure what data to include or exclude based on time ranges.", + "reports_schedule_card_copy": "Add one or more time windows to control which readings are included in this report.", + "reports_schedule_empty": "No data processing schedules yet. Add one to filter report data by time.", + "reports_schedule_entry_heading": "Schedule {index}", + "reports_schedule_day_of_week": "Day of week", + "reports_schedule_rule_type": "Rule type", + "reports_schedule_rule_type_include": "Include", + "reports_schedule_rule_type_exclude": "Exclude", + "reports_schedule_start_time": "Start time", + "reports_schedule_end_time": "End time", + "reports_schedule_timezone": "Timezone", + "reports_schedule_crosses_midnight": "Crosses midnight", + "reports_schedule_crosses_midnight_description": "The time range spans into the following day.", + "reports_schedule_is_enabled": "Enabled", + "reports_schedule_is_enabled_description": "This schedule is active and will be applied.", + "reports_create_validation_report_name_required": "Enter a report name.", + "reports_create_validation_dev_eui_length": "Choose a device.", + "reports_create_validation_schedule_dev_eui": "Schedule {index}: choose a device.", + "reports_create_validation_schedule_cadence": "Schedule {index}: choose at least one delivery option.", + "reports_create_validation_alert_name": "Alert {index}: enter a name.", + "reports_create_validation_alert_metric": "Alert {index}: choose a measure.", + "reports_create_validation_alert_operator": "Alert {index}: choose a condition.", + "reports_create_validation_alert_range_required": "Alert {index}: enter both a minimum and maximum.", + "reports_create_validation_alert_range_order": "Alert {index}: the minimum must be lower than the maximum.", + "reports_create_validation_alert_value": "Alert {index}: enter a threshold.", + "reports_create_validation_alert_hex": "Alert {index}: enter a valid color.", + "reports_create_validation_recipient_method": "Recipient {index}: choose how to send it.", + "reports_create_validation_recipient_destination": "Recipient {index}: enter an email address.", + "reports_create_validation_recipient_email": "Recipient {index}: enter a valid email address.", + "permission_level_admin": "Admin", + "permission_level_manager": "Manager", + "permission_level_user": "User", + "permission_level_viewer": "Viewer", + "permission_level_disabled": "Disabled", + "rule_operator_greater_than": "Greater than (>)", + "rule_operator_less_than": "Less than (<)", + "rule_operator_equal_to": "Equal to (=)", + "rule_operator_greater_or_equal": "Greater or equal (>=)", + "rule_operator_less_or_equal": "Less or equal (<=)", + "rule_operator_not_equal": "Not equal (!=)", + "rule_notifier_email": "Email", + "rule_notifier_sms": "SMS", + "rule_notifier_push": "Push Notification", + "rule_notifier_discord": "Discord", + "rule_send_email": "Email", + "rule_send_sms": "SMS", + "rule_send_both": "Both", + "rule_subject_temperature": "Temperature (°C)", + "rule_subject_humidity": "Humidity (%)", + "rule_subject_co2": "CO₂ (ppm)", + "rule_subject_co": "CO (ppm)", + "rule_subject_pressure": "Pressure (hPa)", + "rule_subject_lux": "Lux (Light)", + "rule_subject_uv_index": "UV Index", + "rule_subject_wind_speed": "Wind Speed", + "rule_subject_wind_direction": "Wind Direction", + "rule_subject_rainfall": "Rainfall", + "rule_subject_battery_level": "Battery Level", + "rule_subject_soil_moisture": "Soil Moisture", + "rule_subject_electrical_conductivity": "Electrical Conductivity (EC)", + "rule_subject_ph": "pH", + "rule_subject_water_depth": "Water Depth (cm)", + "rule_subject_spo2": "SpO₂", + "validation_name_required": "Name is required.", + "validation_email_required": "Email is required.", + "validation_user_email_required": "User email is required.", + "validation_valid_email_required": "Enter a valid email address.", + "validation_correct_highlighted_fields": "Please correct the highlighted fields.", + "billing_account_status": "Account: {status}", + "billing_active_seats": "Active Seats", + "billing_active_seats_subtitle": "Currently billed subscriptions", + "billing_allow_discount_codes": "Allow discount codes", + "billing_allow_trial": "Allow trial", + "billing_api_warnings": "API Warnings", + "billing_api_warnings_subtitle": "Some endpoints returned errors", + "billing_archived": "Archived", + "billing_available_products": "Available Products", + "billing_available_products_subtitle": "Returned by payments API", + "billing_billed_interval": "Billed {interval}", + "billing_cancel_subscription_body": "Cancel {productName} ({id})?", + "billing_cancel_subscription_title": "Cancel Subscription", + "billing_cancel_subscription_warning": "This action calls the revoke endpoint and cannot be undone from this page.", + "billing_canceled": "Canceled", + "billing_canceled_subtitle": "Historical cancellations", + "billing_checkout_redirect_missing": "Checkout session was created, but no redirect URL was returned.", + "billing_checkout_session_created": "Checkout session created.", + "billing_checkout_session_failed": "Unable to create checkout session.", + "billing_checkout_subtitle": "Choose one or more products, then launch hosted checkout", + "billing_checkout_title": "Buy Seats & Devices", + "billing_clear_selection": "Clear Selection", + "billing_column_plan": "Plan", + "billing_column_price": "Price", + "billing_column_renews": "Renews", + "billing_column_started": "Started", + "billing_column_status": "Status", + "billing_confirm_cancel": "Confirm Cancel", + "billing_custom_pricing": "Custom pricing", + "billing_customer_email": "Customer email", + "billing_customer_email_placeholder": "jane@example.com", + "billing_customer_name": "Customer name", + "billing_customer_name_placeholder": "Jane Smith", + "billing_ended": "Ended", + "billing_error_products": "Products: {message}", + "billing_error_state": "State: {message}", + "billing_error_subscriptions": "Subscriptions: {message}", + "billing_heading": "Billing & Subscriptions", + "billing_include_archived_products": "Include archived products", + "billing_include_in_checkout": "Include in checkout", + "billing_keep_subscription": "Keep Subscription", + "billing_launch_checkout": "Launch Checkout", + "billing_load_products_failed": "Unable to load billing products.", + "billing_load_state_failed": "Unable to load subscription state.", + "billing_load_subscriptions_failed": "Unable to load subscriptions.", + "billing_no_product_description": "No description provided by API.", + "billing_no_products": "No products found for checkout.", + "billing_one_time": "One-time", + "billing_open_portal": "Open Billing Portal", + "billing_optional_checkout_settings": "Optional Checkout Settings", + "billing_portal_open_failed": "Unable to open billing portal.", + "billing_portal_opened": "Billing portal opened.", + "billing_portal_redirect_missing": "Portal session was created, but no redirect URL was returned.", + "billing_portal_session_created": "Billing portal session created.", + "billing_select_product_before_checkout": "Select at least one product before checkout.", + "billing_selected_count": "{count} selected", + "billing_status_active": "Active", + "billing_status_canceled": "Canceled", + "billing_status_past_due": "Past due", + "billing_status_trial": "Trial", + "billing_status_unknown": "Unknown", + "billing_status_unpaid": "Unpaid", + "billing_subscription_cancel_failed": "Unable to cancel subscription.", + "billing_subscription_canceled": "Subscription canceled successfully.", + "billing_subscription_id_required": "Subscription id is required.", + "billing_subscriptions_subtitle": "Current and historical subscription records", + "billing_subscriptions_title": "Subscriptions", + "billing_subtitle": "Manage plan purchases, active subscriptions, and customer portal access.", + "billing_trial_plans": "Trial Plans", + "billing_trial_plans_subtitle": "In trial period", + "billing_unknown_plan": "Unknown plan", + "common_location": "Location", + "common_weather": "Weather", + "dashboard_active_alert_alt": "Active alert", + "dashboard_column_alerts": "Alerts", + "dashboard_column_co2": "CO2 (ppm)", + "dashboard_column_device_name": "Device Name", + "dashboard_column_humidity": "Humidity (%)", + "dashboard_column_last_seen": "Last Seen", + "dashboard_column_location": "Location", + "dashboard_column_soil_humidity": "Soil Humidity (%)", + "dashboard_column_soil_temperature": "Soil Temperature (°C)", + "dashboard_column_temperature": "Temperature (°C)", + "dashboard_loading_more_locations_hint": "Loading 10 more locations when you reach the fifth remaining card.", + "dashboard_no_alerts_alt": "No alerts", + "dashboard_no_data_yet": "No data yet", + "dashboard_no_matching_locations_body": "Adjust the dashboard filters or clear them to load more locations.", + "dashboard_no_matching_locations_title": "No devices match these filters", + "dashboard_virtual_scroll": "Virtual Scroll", + "demo_paraglide_link": "Paraglide demo", + "demo_paraglide_sherlock_link": "Sherlock i18n extension", + "demo_paraglide_vscode_prefix": "If you use VSCode, install the ", + "demo_paraglide_vscode_suffix": " for a better i18n experience.", + "devices_back_to_detail": "Back To Device Detail", + "devices_back_to_location": "Back To Location", + "devices_choose_valid_permission_level": "Choose a valid permission level.", + "devices_create_failed": "Failed to create device. The current API docs still mark this endpoint as not implemented.", + "devices_create_form_note": "This form maps directly to the new cw_device and cw_device_owners DTOs. The type field should match cw_device_type.id.", + "devices_create_page_subtitle": "Prepare a new cw_device payload for location {locationId}", + "devices_create_page_title": "Create Device", + "devices_create_submit": "Create Device", + "devices_csv_export": "CSV", + "devices_current_group_chip": "Current group: {group}", + "devices_dashboard_card_subtitle": "Location {locationName}", + "devices_dashboard_card_title": "Device {devEui}", + "devices_dashboard_page_title": "Device Dashboard - {devEui} - CropWatch", + "devices_deployment_section_subtitle": "Optional placement and lifecycle values stored on the device row.", + "devices_deployment_section_title": "Deployment", + "devices_dev_eui_invalid": "Device EUI must contain exactly 16 hexadecimal characters.", + "devices_dev_eui_label": "Device EUI", + "devices_dev_eui_placeholder": "A1:B2:C3:D4:E5:F6:07:08", + "devices_dev_eui_required": "Device EUI is required.", + "devices_deveui_chip": "DevEUI {devEui}", + "devices_device_group_length": "Device group must be {max} characters or fewer.", + "devices_device_name_label": "Device Name", + "devices_device_name_length": "Device name must be {max} characters or fewer.", + "devices_device_name_placeholder": "Greenhouse Sensor 1", + "devices_device_name_required": "Device name is required.", + "devices_device_type_label": "Device Type", + "devices_device_type_placeholder": "Select a device type...", + "devices_device_type_required": "Device type id is required.", + "devices_download_pdf": "Download PDF", + "devices_export_dialog_title": "Export Telemetry Data", + "devices_export_failed": "Unable to export telemetry for the selected range.", + "devices_export_no_data": "No telemetry was found for the selected date range.", + "devices_export_select_range": "Select export range", + "devices_export_success": "Exported {count} telemetry rows.", + "devices_export_timezone_hint": "Request and export timestamps use {timeZone}.", + "devices_group_placeholder": "Optional device group", + "devices_identity_section_subtitle": "Core cw_device fields used for the initial POST payload.", + "devices_identity_section_title": "Device Identity", + "devices_installed_at_label": "Installed At", + "devices_installed_at_placeholder": "Select install date", + "devices_invalid_certificate_target": "Invalid certificate target.", + "devices_invalid_device_id": "Invalid device id.", + "devices_libellus_api_token_missing": "PRIVATE_LIBELLUS_API_TOKEN is not configured.", + "devices_libellus_base_url_missing": "PRIVATE_LIBELLUS_BASE_URL is not configured.", + "devices_libellus_product_name_missing": "cw_device_type.model is missing, so Libellus product_name is unknown.", + "devices_libellus_request_failed": "Libellus request failed.", + "devices_load_device_types_failed": "Failed to load device types.", + "devices_load_telemetry_failed": "Unable to load telemetry for the selected range.", + "devices_loading_telemetry": "Loading telemetry…", + "devices_location_id_label": "Location ID", + "devices_location_id_required": "Location id is required.", + "devices_location_required": "Location is required.", + "devices_no_data_yet": "No data yet for this device.", + "devices_no_device_owners": "No device owners were returned for this device.", + "devices_no_sensor_serial": "No sensor serial value is available for this device.", + "devices_no_sensor_serial_configured": "No sensor serial is configured for this device.", + "devices_not_found": "Device not found.", + "devices_owner_email_invalid": "Owner email must be a valid email address.", + "devices_owner_email_required": "Owner email is required.", + "devices_owner_fallback_name": "User {index}", + "devices_owner_permissions_subtitle": "Update only. Add and delete actions are intentionally omitted here.", + "devices_owner_permissions_title": "Device Owner Permissions", + "devices_permission_update_rejected": "The API rejected the permission update request.", + "devices_permission_updated_for_email": "Permission updated for {email}.", + "devices_range_last_hours": "Last {hours} Hours", + "devices_range_today_only": "Today Only", + "devices_save_note_failed": "Unable to save note.", + "devices_save_note_requires_login": "You must be logged in to save a note.", + "devices_save_note_success": "Note saved successfully.", + "devices_sensor_certificate_note": "Downloads the individual ISO17025 PDF returned by Sensirion Libellus for this serial number.", + "devices_sensor_certificate_requires_login": "You must be logged in to download sensor certificates.", + "devices_sensor_certificates_subtitle": "Download the Libellus PDF certificate for each configured sensor serial.", + "devices_sensor_certificates_title": "Device Sensor Certificates", + "devices_sensor_one": "Sensor 1", + "devices_sensor_serial_chip": "Serial {serial}", + "devices_sensor_two": "Sensor 2", + "devices_settings_page_title": "Device Settings | {devEui} | CropWatch", + "devices_settings_subtitle": "Update the cw_device name and group fields.", + "devices_settings_title": "Device Settings", + "devices_settings_update_rejected": "The API rejected the update request.", + "devices_settings_updated": "Device settings updated.", + "devices_tti_device_id_label": "TTI Device ID", + "devices_tti_device_id_placeholder": "e.g. relay-field-01", + "devices_tti_device_id_invalid": "TTI device IDs must use lowercase letters, numbers, and hyphens only.", + "devices_unassigned_location": "Unassigned", + "devices_unknown_location": "Unknown", + "devices_update_owner_permission_requires_login": "You must be logged in to update device owner permissions.", + "devices_update_permission": "Update Permission", + "devices_update_requires_login": "You must be logged in to update device settings.", + "devices_relay_controls_requires_permission": "You need manager access or higher to queue relay commands.", + "devices_relay_turn_on": "Turn On", + "devices_relay_turn_off": "Turn Off", + "devices_relay_command_queued": "Relay {relay} command sent. Verifying in 15 seconds.", + "devices_relay_command_failed": "Unable to update the relay right now.", + "devices_relay_downlink_not_authorized": "Relay control is not authorized for this device.", + "devices_relay_downlink_target_not_found": "The relay device could not be found.", + "devices_relay_downlink_rate_limited": "Relay requests are being rate limited. Please wait and try again.", + "devices_relay_confirmation_pending": "Pending confirmation", + "devices_relay_waiting_for_confirmation": "Command sent. Waiting for confirmation.", + "devices_relay_confirmation_checking_again_in": "Checking again in", + "devices_relay_confirmation_changed": "Relay {relay} verified {state}.", + "devices_relay_confirmation_mismatch": "Relay {relay} verification did not match. API reports {state}.", + "devices_relay_state_unknown": "No confirmed relay telemetry yet.", + "devices_relay_pulse_panel_title": "Timed pulse control", + "devices_relay_pulse_description": "Turn a relay on for a fixed number of seconds and let it revert automatically. The minimum pulse is 15 seconds.", + "devices_relay_pulse_duration_label": "Pulse duration (seconds)", + "devices_relay_pulse_action": "Pulse {relay}", + "devices_relay_pulse_duration_error": "Enter a whole number of seconds between {minSeconds} and {maxSeconds}.", + "devices_update_submit": "Update Device", + "devices_upload_interval_label": "Upload Interval (minutes)", + "devices_upload_interval_placeholder": "15", + "devices_validation_runs_note": "Validation runs in the browser and again on the server.", + "display_add_note": "Add Note", + "display_add_note_body": "Add a note for the telemetry entry at {createdAt} with temperature {temperature}°C.", + "display_add_note_title": "Add Note for {createdAt}", + "display_current_co2": "Current CO2", + "display_current_humidity": "Current Humidity", + "display_current_state": "Current state", + "display_current_temperature": "Current Temperature", + "display_depth": "Depth", + "display_device_data": "Device Data", + "display_enter_note_here": "Enter your note here...", + "display_generic_view": "Generic view", + "display_last_updated": "Last updated", + "display_latest_reading": "Latest reading", + "display_loading_data": "Loading data…", + "display_loading_power_data": "Fetching power data…", + "display_no_air_quality_data_selected_range": "No air quality data available for the selected range.", + "display_no_data": "No Data", + "display_no_data_selected_range": "No data available for the selected range.", + "display_no_power_data_selected_range": "No power data available for the selected range.", + "display_no_relay_history": "No relay history available.", + "display_no_soil_data_selected_range": "No soil data available for the selected range.", + "display_no_water_data_selected_range": "No water data available for the selected range.", + "display_off": "OFF", + "display_on": "ON", + "display_unknown": "Unknown", + "display_power": "Power", + "display_power_data": "Power Data", + "display_reading_density": "Reading density", + "display_relay_history": "Relay History", + "display_relay_one": "Relay 1", + "display_relay_two": "Relay 2", + "display_save_note": "Save Note", + "display_searchable_sortable": "Searchable, sortable", + "display_searchable_sortable_data": "Searchable, sortable data", + "display_soil_moisture_temperature": "Soil Moisture & Temperature", + "display_soil_telemetry": "Soil Telemetry", + "display_state_change_log": "State change log", + "display_telemetry_table": "Telemetry Table", + "display_temperature_heatmap": "Temperature Heatmap", + "display_temperature_humidity_chart": "Temperature & Humidity", + "display_time_series": "Time series", + "display_timestamp": "Timestamp", + "display_view_notes": "View Notes", + "display_view_notes_title": "View Notes for {createdAt}", + "display_water_depth": "Water Depth", + "display_water_telemetry": "Water Telemetry", + "display_water_temperature": "Water Temperature", + "manifest_description": "Monitor locations, devices, rules, and reports with an installable CropWatch app experience.", + "manifest_shortcut_dashboard_description": "Open the device dashboard.", + "manifest_shortcut_dashboard_name": "Dashboard", + "manifest_shortcut_locations_description": "Jump straight to all locations.", + "manifest_shortcut_locations_name": "Locations", + "manifest_shortcut_reports_description": "Open reporting history and schedules.", + "manifest_shortcut_reports_name": "Reports", + "manifest_shortcut_rules_description": "Review and manage rules.", + "manifest_shortcut_rules_name": "Rules", + "traffic_calendar_auth_required": "Calendar data requires an authenticated session.", + "traffic_calendar_fetch_subtitle": "{monthLabel} calendar fetch", + "traffic_calendar_total": "Traffic Total: {count}", + "traffic_current_hour_today": "Current hour today", + "traffic_current_hour_total": "Current Hour Total", + "traffic_daily_traffic_weather": "Daily Traffic & Weather", + "traffic_daily_weather": "Daily Weather", + "traffic_device_coordinates_unavailable": "Device coordinates unavailable", + "traffic_download_hourly_csv": "Download Hourly CSV", + "traffic_fetching_traffic_data": "Fetching traffic data...", + "traffic_hour": "Hour", + "traffic_hourly_traffic": "Hourly Traffic", + "traffic_hourly_traffic_subtitle": "Summed by hour for today", + "traffic_lat_lng_label": "Lat/Lng {label}", + "traffic_latest_available_hour_today": "Latest available hour today", + "traffic_load_month_failed": "Unable to load traffic for the visible month.", + "traffic_loading_current_traffic": "Loading current traffic...", + "traffic_loading_visible_month": "Loading visible month...", + "traffic_loading_weather": "Loading weather...", + "traffic_low_temperature": "Low {value}", + "traffic_month_traffic_days": "Month traffic {count} day(s)", + "traffic_no_class_data_recorded": "No class data recorded.", + "traffic_no_data_this_month": "No traffic recorded for this month", + "traffic_no_data_today": "No traffic data is available for today.", + "traffic_no_traffic_today": "No traffic today", + "traffic_precip_summary": "{value} mm precip", + "traffic_preparing_csv": "Preparing CSV...", + "traffic_raw_records_count": "{count} raw records", + "traffic_samples": "Samples", + "traffic_samples_count": "{count} samples", + "traffic_selected_period": "Selected Period", + "traffic_today_only": "Today only", + "traffic_today_via_coordinates": "Today via device coordinates", + "traffic_total_traffic": "Total Traffic", + "traffic_tracked_hours_count": "{count} tracked hour(s)", + "traffic_unavailable": "Unavailable", + "traffic_weather_days_count": "{count} weather day(s)", + "traffic_weather_requires_coordinates": "Weather requires device latitude and longitude.", + "traffic_weather_today_failed": "Unable to load weather for today.", + "traffic_weather_unavailable": "Weather unavailable", + "traffic_wind_summary": "{value} km/h wind", + "weather_clear": "Clear", + "weather_clear_sky": "Clear sky", + "weather_cloudy": "Cloudy", + "weather_drizzle": "Drizzle", + "weather_fog": "Fog", + "weather_foggy": "Foggy", + "weather_mostly_clear": "Mostly Clear", + "weather_mostly_clear_description": "Mostly clear", + "weather_overcast": "Overcast", + "weather_partly_cloudy": "Partly Cloudy", + "weather_partly_cloudy_description": "Partly cloudy", + "weather_rain": "Rain", + "weather_snow": "Snow", + "weather_storm": "Storm", + "weather_thunderstorm": "Thunderstorm", + "display_temperature": "Current Temperature", + "display_temperature_humidity": "Current Humidity", + "stat_min": "Min", + "stat_avg": "Avg", + "stat_max": "Max", + "stat_count": "Count", + "stat_median": "Median", + "stat_stdDev": "Std Dev", + "stat_range": "Range", + "stat_aboveAvg": "Above Avg", + "stat_belowAvg": "Below Avg", + "stat_atAvg": "At Avg" +} diff --git a/messages/ja.json b/messages/ja.json new file mode 100644 index 00000000..1c6909fa --- /dev/null +++ b/messages/ja.json @@ -0,0 +1,870 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "CropWatch", + "hello_world": "{name}さん、こんにちは!", + "common_name": "名前", + "common_description": "説明", + "common_group": "グループ", + "common_created": "作成日時", + "common_actions": "操作", + "common_all_short": "ALL", + "common_not_available": "N/A", + "action_back": "戻る", + "action_back_to_dashboard": "ダッシュボードに戻る", + "action_cancel": "キャンセル", + "action_close": "閉じる", + "action_delete": "削除", + "action_dismiss": "閉じる", + "action_download": "ダウンロード", + "action_edit": "編集", + "action_go_back": "前へ戻る", + "action_go_home": "ホームへ戻る", + "action_install": "インストール", + "action_later": "後で", + "action_not_now": "今はしない", + "action_remove": "削除", + "action_save_changes": "変更を保存", + "action_saving": "保存中…", + "action_try_again": "再試行", + "action_view": "表示", + "common_day_sunday": "日曜日", + "common_day_monday": "月曜日", + "common_day_tuesday": "火曜日", + "common_day_wednesday": "水曜日", + "common_day_thursday": "木曜日", + "common_day_friday": "金曜日", + "common_day_saturday": "土曜日", + "nav_dashboard": "ダッシュボード", + "nav_locations": "ロケーション", + "nav_rules": "ルール", + "nav_reports": "レポート", + "nav_profile": "プロフィール", + "nav_billing": "請求", + "nav_settings": "設定", + "nav_logout": "ログアウト", + "header_privacy_mode": "プライバシーモード", + "header_logout_error": "ログアウト中にエラーが発生しました。もう一度お試しください。", + "status_online": "オンライン", + "status_offline": "オフライン", + "status_warning": "警告", + "status_loading": "読み込み中", + "status_alerts": "アラート", + "sidebar_all_groups": "すべてのグループ", + "sidebar_all_location_groups": "すべてのロケーショングループ", + "sidebar_all_locations": "すべてのロケーション", + "sidebar_search_devices_placeholder": "デバイスを検索...", + "sidebar_dashboard_filters": "ダッシュボードフィルター", + "sidebar_filter_devices_in_view": "表示中のデバイスを絞り込み", + "sidebar_device_groups": "デバイスグループ", + "sidebar_location_groups": "ロケーショングループ", + "sidebar_locations": "ロケーション", + "sidebar_current_session_expires": "現在のセッション期限", + "overview_online_count": "オンライン {count}", + "overview_offline_count": "オフライン {count}", + "overview_alert_count": "アラート {count}", + "overview_status_mix": "ステータス内訳", + "overview_total": "合計 {count}", + "overview_top_groups": "上位グループ", + "overview_in_view": "表示中", + "overview_active_alert_list": "アクティブアラート一覧", + "overview_reported_time": "報告時刻", + "overview_ungrouped": "未分類", + "dashboard_page_title": "CropWatch ダッシュボード", + "dashboard_devices_in_view": "表示中のデバイス", + "dashboard_with_active_alerts": "アクティブアラートあり", + "dashboard_total_online": "オンライン合計: {count}", + "dashboard_total_offline": "オフライン合計: {count}", + "dashboard_table_view": "テーブル", + "dashboard_sensor_cards_view": "センサーカード", + "dashboard_notifications_alt": "通知", + "dashboard_loading_view": "ダッシュボードを読み込み中...", + "dashboard_loading_devices": "デバイスを読み込み中…", + "error_bad_request_title": "不正なリクエスト", + "error_bad_request_description": "サーバーがリクエストを理解できませんでした。URL を確認して再度お試しください。", + "error_unauthorized_title": "認証が必要です", + "error_unauthorized_description": "このページにアクセスするにはログインが必要です。", + "error_forbidden_title": "アクセス禁止", + "error_forbidden_description": "このリソースにアクセスする権限がありません。", + "error_not_found_title": "ページが見つかりません", + "error_not_found_description": "お探しのページは存在しないか、移動されました。", + "error_timeout_title": "リクエストがタイムアウトしました", + "error_timeout_description": "サーバーの応答が間に合いませんでした。再度お試しください。", + "error_too_many_requests_title": "リクエストが多すぎます", + "error_too_many_requests_description": "短時間に多数のリクエストが送信されました。しばらく待ってから再度お試しください。", + "error_internal_server_title": "サーバー内部エラー", + "error_internal_server_description": "サーバー側で問題が発生しました。後ほど再度お試しください。", + "error_bad_gateway_title": "不正なゲートウェイ応答", + "error_bad_gateway_description": "サーバーが無効な応答を受け取りました。後ほど再度お試しください。", + "error_service_unavailable_title": "サービスを利用できません", + "error_service_unavailable_description": "サービスは一時的に利用できません。後ほど再度お試しください。", + "error_gateway_timeout_title": "ゲートウェイタイムアウト", + "error_gateway_timeout_description": "サーバーの応答に時間がかかりすぎました。後ほど再度お試しください。", + "error_unexpected_title": "予期しないエラー", + "error_unknown": "不明なエラーが発生しました。", + "error_reset_and_login": "リセットしてサインイン", + "offline_page_title": "CropWatch オフライン", + "offline_page_description": "CropWatch はオフラインです。ライブのデバイスデータ、レポート、設定を読み込むには再接続してください。", + "offline_eyebrow": "オフライン", + "offline_heading": "ライブデータの表示には接続が必要です。", + "offline_body": "アプリ本体は利用可能ですが、ダッシュボード、レポート、設定の表示にはネットワーク接続が必要です。", + "pwa_update_ready": "更新の準備完了", + "pwa_update_title": "最新の CropWatch に更新できます。", + "pwa_update_body": "新しいアプリシェルのダウンロードが完了しており、すぐに有効化できます。", + "pwa_update_now": "今すぐ更新", + "pwa_install_app": "アプリをインストール", + "pwa_install_title": "CropWatch をネイティブアプリのように使えます。", + "pwa_install_body": "ホーム画面やタスクバー、デスクトップからすばやく起動でき、オフライン対応のアプリシェルも利用できます。", + "pwa_install_iphone": "iPhone に追加", + "pwa_install_iphone_title": "Safari からホーム画面に追加できます。", + "pwa_install_iphone_body": "Safari の共有メニューを開き、ホーム画面に追加を選ぶとフルスクリーンで利用できます。", + "auth_email_label": "メールアドレス", + "auth_email_label_required": "メールアドレス *", + "auth_email_placeholder": "you@example.com", + "auth_password_label": "パスワード", + "auth_password_label_required": "パスワード *", + "auth_password_placeholder": "••••••••", + "auth_sign_in": "サインイン", + "auth_signing_in": "サインイン中...", + "auth_sign_in_instead": "代わりにサインイン", + "auth_sign_in_existing_account": "既存のアカウントでサインイン", + "auth_login_page_title": "ログイン - CropWatch", + "auth_login_heading": "CropWatch へようこそ!", + "auth_login_subtitle": "最新の更新を確認するにはアカウントにサインインしてください", + "auth_login_reason_auth_required": "続行するにはサインインしてください。", + "auth_login_reason_expired": "セッションの有効期限が切れました。続行するには再度サインインしてください。", + "auth_login_reason_signed_out": "サインアウトしました。", + "auth_login_reason_password_reset": "新しいパスワードで再度サインインしてください。", + "auth_login_reason_error_recovery": "エラーが発生しました。保存データをクリアしました。再度サインインしてください。", + "auth_login_success_redirecting": "ログインに成功しました。リダイレクトしています...", + "auth_login_failed": "現在サインインできません。しばらくしてから再度お試しください。", + "auth_invalid_credentials": "メールアドレスまたはパスワードが正しくありません。", + "auth_invalid_form_submission": "フォーム送信が無効です。", + "auth_security_copy": "reCAPTCHA と CropWatch Security により保護されています", + "auth_security_refresh_error": "サイトのセキュリティを確認できませんでした。ページを更新して再度お試しください。", + "auth_security_try_again": "セキュリティ確認に失敗しました。もう一度お試しください。", + "auth_loading_site_security": "サイトのセキュリティを読み込み中...", + "auth_create_account": "アカウントを作成", + "auth_create_account_page_title": "アカウント作成 - CropWatch", + "auth_creating_account": "アカウントを作成中...", + "auth_or_prefix": "または ", + "auth_first_name_label_required": "名 *", + "auth_first_name_placeholder": "Taro", + "auth_last_name_label_required": "姓 *", + "auth_last_name_placeholder": "Yamada", + "auth_password_requirements": "パスワード要件", + "auth_password_requirement_length": "6文字以上", + "auth_password_requirement_lowercase": "小文字を含む (a-z)", + "auth_password_requirement_uppercase": "大文字を含む (A-Z)", + "auth_password_requirement_number": "数字を含む (0-9)", + "auth_password_requirement_symbol": "記号を含む (!@#$ など)", + "auth_confirm_password_label_required": "パスワード確認 *", + "auth_passwords_match": "✓ パスワードが一致しています", + "auth_passwords_do_not_match": "パスワードが一致しません", + "auth_passwords_do_not_match_plain": "パスワードが一致しません。", + "auth_company_name_label_required": "会社名 *", + "auth_company_name_placeholder": "Acme Corp", + "auth_required_agreements_label": "必須同意事項 *", + "auth_agree_to": "", + "auth_terms_of_service": "利用規約に同意する", + "auth_privacy_policy": "プライバシーポリシーに同意する", + "auth_cookie_policy": "Cookie ポリシーに同意する", + "auth_all_three_required": "登録には 3 つすべてへの同意が必要です。", + "auth_all_fields_required": "すべての項目を入力してください。", + "auth_must_agree_all_policies": "登録するには必須ポリシーすべてに同意してください。", + "auth_account_exists": "このメールアドレスのアカウントはすでに存在します。", + "auth_registration_failed": "現在アカウントを作成できません。しばらくしてから再度お試しください。", + "auth_not_authenticated": "認証されていません。", + "auth_check_email_page_title": "メールを確認 - CropWatch", + "auth_check_email_heading": "メールを確認してください", + "auth_check_email_account_created": "アカウントが作成されました!", + "auth_check_email_activation_link_prefix": "入力したメールアドレスに", + "auth_check_email_activation_link_highlight": "有効化リンク", + "auth_check_email_activation_link_suffix": "を送信しました。", + "auth_check_email_open_inbox_prefix": "受信トレイで CropWatch からのメールを開き、", + "auth_check_email_open_inbox_highlight": "メール内のリンクをクリック", + "auth_check_email_open_inbox_suffix": "してアカウントを有効化してください。", + "auth_check_email_missing_heading": "メールが見つかりませんか?", + "auth_check_email_tip_spam": "迷惑メールフォルダを確認してください", + "auth_check_email_tip_address": "入力したメールアドレスが正しいか確認してください", + "auth_check_email_tip_delivery": "届くまで数分お待ちください", + "auth_check_email_you": "YOU", + "auth_check_email_check": "CHECK", + "auth_check_email_email": "EMAIL", + "auth_check_email_spam": "SPAM", + "auth_check_email_next": "Next", + "auth_check_email_cant_find_it": "見つかりませんか?", + "auth_check_email_not_there": "それでもありませんか?", + "auth_check_email_contact_support": "サポートへ連絡してください", + "auth_go_to_sign_in": "サインインへ進む", + "auth_forgot_password": "パスワードをお忘れですか?", + "auth_forgot_password_page_title": "パスワードを忘れた場合 - CropWatch", + "auth_forgot_password_subtitle": "アカウントのメールアドレスを入力すると、再設定リンクを送信します。", + "auth_forgot_password_sent_body": "そのメールアドレスのアカウントが存在する場合、パスワード再設定リンクを送信しました。受信トレイと迷惑メールフォルダを確認してください。", + "auth_back_to_login": "ログインに戻る", + "auth_send_reset_link": "再設定リンクを送信", + "auth_sending": "送信中…", + "auth_enter_email": "メールアドレスを入力してください。", + "locations_all_title": "すべてのロケーション", + "locations_all_subtitle": "現在のデバイステレメトリから集計", + "locations_add_location": "ロケーションを追加", + "locations_create_title": "ロケーションを作成", + "locations_create_subtitle": "アカウントに新しいロケーションを追加", + "locations_name_placeholder": "例: Greenhouse A", + "locations_optional_description": "任意の説明", + "locations_optional_group_name": "任意のグループ名", + "locations_latitude_optional": "緯度 (任意)", + "locations_latitude_placeholder": "例: 35.6762", + "locations_longitude_optional": "経度 (任意)", + "locations_longitude_placeholder": "例: 139.6503", + "locations_create_submit": "ロケーションを作成", + "locations_create_failed": "ロケーションの作成に失敗しました。", + "locations_latitude_must_be_number": "緯度は数値で入力してください。", + "locations_longitude_must_be_number": "経度は数値で入力してください。", + "locations_missing_location_id": "ロケーションは作成されましたが、API レスポンスに location_id が含まれていませんでした。", + "locations_current_location": "現在のロケーション", + "locations_location_with_id": "ロケーション {id}", + "locations_location_title": "ロケーション: {name}", + "locations_devices_for_location": "このロケーションに紐づくデバイス", + "locations_settings_page_title": "ロケーション設定 - {name}", + "locations_back_to_location": "ロケーションに戻る", + "locations_settings_title": "ロケーション設定", + "locations_settings_subtitle": "ロケーション設定を管理", + "locations_location_name": "ロケーション名", + "locations_enter_location_name": "ロケーション名を入力", + "locations_enter_group_name": "グループ名を入力", + "locations_update_location_name": "ロケーション名を更新", + "locations_update_name_requires_login": "ロケーション名を更新するにはログインが必要です。", + "locations_invalid_location_id": "無効な location id です。", + "locations_name_cannot_be_empty": "ロケーション名は空にできません。", + "locations_name_updated": "ロケーション名を更新しました。", + "locations_update_name_failed": "ロケーション名を更新できませんでした。", + "locations_permission_denied": "このロケーション設定ページにアクセスする権限がありません。", + "locations_add_user_permissions_title": "{name} にユーザー権限を追加", + "locations_add_user_permissions_subtitle": "このロケーションに対するユーザー権限を作成します", + "locations_permission_level": "権限レベル", + "locations_apply_to_all_devices": "このロケーション内のすべてのデバイスに適用", + "locations_add_permission": "権限を追加", + "locations_manage_permissions_requires_login": "ロケーション権限を管理するにはログインが必要です。", + "locations_permission_created": "ロケーション権限を作成しました。", + "locations_permission_create_failed": "ロケーション権限を作成できませんでした。", + "locations_select_permission_to_edit": "編集する権限を選択してください。", + "locations_user_id_required": "ユーザー ID は必須です。", + "locations_missing_admin_user_id": "現在のセッションに管理者ユーザー ID がありません。", + "locations_permission_updated": "ロケーション権限を更新しました。", + "locations_permission_update_failed": "ロケーション権限を更新できませんでした。", + "locations_choose_permission_level": "権限レベルを選択…", + "locations_permission_level_required": "権限レベルは必須です。", + "locations_user_permission_level_updated": "ユーザーの権限レベルを更新しました。", + "locations_user_permission_level_update_failed": "ユーザーの権限レベルを更新できませんでした。", + "locations_permission_id_required": "permission_id は必須です。", + "locations_permission_removed": "ロケーション権限を削除しました。", + "locations_permission_remove_failed": "ロケーション権限を削除できませんでした。", + "locations_confirm_delete_permission_title": "権限を削除", + "locations_confirm_delete_permission_body": "{email} の権限を削除してもよろしいですか?", + "locations_delete_permission_failed": "権限の削除に失敗しました。", + "devices_device": "デバイス", + "devices_add_device": "デバイスを追加", + "devices_device_name": "デバイス名", + "devices_unnamed_device": "名称未設定のデバイス", + "rules_page_title": "ルール - CropWatch", + "rules_configured_rules": "設定済みルール", + "rules_create_new_rule": "新しいルールを作成", + "rules_view_rule": "ルールを表示", + "rules_edit_rule": "ルールを編集", + "rules_delete_rule": "ルールを削除", + "rules_delete_confirmation": "ルール「{name}」を削除してもよろしいですか?この操作は元に戻せません。", + "rules_delete_failed": "このルールを削除できません。", + "rules_rule_name": "ルール名", + "rules_rule_name_placeholder": "例: High Temperature Alert", + "rules_notification_type": "通知タイプ", + "rules_send_using": "送信方法", + "rules_recipient": "送信先", + "rules_recipient_placeholder": "alert@example.com", + "rules_step_1_title": "1. ルール詳細", + "rules_step_1_subtitle": "ルール名を付け、通知方法を選択します", + "rules_edit_step_1_subtitle": "ルール名と通知設定を更新します", + "rules_step_2_title": "2. デバイスを選択", + "rules_step_2_subtitle": "このルールを監視するデバイスを選択します", + "rules_no_devices_available": "利用可能なデバイスがありません。先にデバイスを追加してください。", + "rules_select_device_placeholder": "デバイスを選択...", + "rules_device_preselected": "クエリパラメータからデバイスが事前選択されています。", + "rules_step_3_title": "3. アラート条件", + "rules_step_3_subtitle": "数値は半角英数字でご入力ください。", + "rules_edit_step_3_subtitle": "このルールを発火させる条件を更新します", + "rules_condition_number": "条件 {count}", + "rules_data_field": "データ項目", + "rules_operator": "演算子", + "rules_trigger_value": "しきい値", + "rules_trigger_value_placeholder": "例: 30", + "rules_reset_value": "リセット値", + "rules_reset_value_placeholder": "トリガーを解除する値", + "rules_reset_value_optional_placeholder": "トリガーを解除する値 (任意)", + "rules_reset_value_help": "センサー値がこの値に戻るとルールがリセットされ、再度発火できるようになります。", + "rules_add_another_condition": "+ 条件を追加", + "rules_step_4_title": "4. 確認して作成", + "rules_step_4_subtitle": "保存前にルール設定を確認します", + "rules_step_4_review_save_title": "4. 確認して保存", + "rules_step_4_review_save_subtitle": "保存前に変更内容を確認します", + "rules_rule_summary": "ルール概要", + "rules_notify_via": "通知方法", + "rules_conditions": "条件", + "rules_trigger_count": "トリガー回数", + "rules_complete_required_fields": "プレビューを表示するには、上の必須項目をすべて入力してください。", + "rules_create_rule": "ルールを作成", + "rules_creating": "作成中…", + "rules_created_success": "ルール「{name}」を作成しました。", + "rules_create_failed": "ルールの作成に失敗しました。もう一度お試しください。", + "rules_updated_success": "ルール「{name}」を更新しました。", + "rules_update_failed": "ルールの更新に失敗しました。もう一度お試しください。", + "rules_invalid_rule_id": "無効なルール ID です", + "rules_rule_not_found": "ルールが見つかりません", + "reports_page_title": "レポート - CropWatch", + "reports_weekly_reports": "週次レポート", + "reports_for_device": "対象デバイス", + "reports_unknown_location": "不明なロケーション", + "reports_unknown_device": "不明なデバイス", + "reports_delete_report": "レポートを削除", + "reports_confirm_delete_title": "レポート削除の確認", + "reports_confirm_delete_body": "レポート「{name}」を削除してもよろしいですか?この操作は元に戻せません。", + "reports_delete_failed": "現在このレポートを削除できません。", + "reports_delete_missing_id": "レポート ID がないため、このレポートを削除できません。", + "reports_deleted_named": "レポート「{name}」を削除しました。", + "reports_deleted_successfully": "レポートを削除しました。", + "reports_history": "履歴", + "reports_history_title": "レポート履歴", + "reports_history_load_failed": "現在レポート履歴を読み込めません。", + "reports_download_failed": "現在このレポートをダウンロードできません。", + "reports_download_missing_signed_url": "署名付き URL がないため、このレポートをダウンロードできません。", + "reports_problem_reports": "問題レポート", + "reports_problem_reports_tooltip": "このレポートは開発中です。後でもう一度ご確認ください。", + "reports_problems_only_report": "問題のみレポート", + "reports_created_at": "作成日時", + "reports_edit_page_title": "レポートを編集", + "reports_create_page_title": "新しいレポート", + "reports_create_page_subtitle": "このレポートの対象デバイス、配信スケジュール、アラート、配信先を設定します。", + "reports_create_intro_eyebrow": "レポート", + "reports_create_stat_schedules": "スケジュール", + "reports_create_stat_active": "有効", + "reports_create_stat_alerts": "アラート", + "reports_create_stat_recipients": "宛先", + "reports_create_invalid_toast": "必須項目を入力してから保存してください。", + "reports_create_success_toast": "レポートを作成しました。", + "reports_create_validation_title": "入力内容を確認してください", + "reports_create_validation_copy": "下の項目を修正してから、もう一度お試しください。", + "reports_create_basics_title": "レポート情報", + "reports_create_basics_subtitle": "レポート名と対象デバイスを選択してください。", + "reports_create_name_placeholder": "週次の運用レポート", + "reports_create_device_placeholder": "デバイスを選択", + "reports_create_field_id": "内部 ID", + "reports_create_field_report_id": "レポート ID", + "reports_create_field_user_id": "ユーザー ID", + "reports_create_field_schedule_id": "スケジュール ID", + "reports_create_resolved_device": "選択中のデバイス", + "reports_create_manual_entry": "デバイス未選択", + "reports_create_resolved_device_hint": "このレポートの対象デバイスを選択してください。", + "reports_create_no_devices_loaded": "利用できるデバイスがありません。", + "reports_create_advanced_report_metadata": "システム項目", + "reports_create_advanced_report_metadata_hint": "通常は空欄のままで問題ありません。", + "reports_create_schedules_title": "配信スケジュール", + "reports_create_schedules_subtitle": "このレポートを送るタイミングを設定します。", + "reports_create_schedules_copy": "必要に応じて、配信スケジュールを追加してください。", + "reports_create_add_schedule": "スケジュールを追加", + "reports_create_empty_schedules": "スケジュールはまだありません。自動配信する場合は追加してください。", + "reports_create_schedule_heading": "スケジュール {index}", + "reports_create_schedule_copy": "このレポートを送る頻度を選択してください。", + "reports_create_schedule_active_label": "有効", + "reports_create_schedule_active_description": "この配信スケジュールを有効にします。", + "reports_create_schedule_week_label": "毎週", + "reports_create_schedule_week_description": "このレポートを毎週送信します。", + "reports_create_schedule_month_label": "月末", + "reports_create_schedule_month_description": "このレポートを月末に送信します。", + "reports_create_schedule_child_device_label": "デバイス ID", + "reports_create_schedule_child_device_placeholder": "メインのデバイス設定を使用", + "reports_create_schedule_user_label": "ユーザー ID", + "reports_create_schedule_user_placeholder": "必要な場合のみ入力", + "reports_create_advanced_schedule_metadata": "システム項目", + "reports_create_alerts_title": "ハイライト設定", + "reports_create_alerts_subtitle": "このレポートに含めるアラート条件を設定します。", + "reports_create_alerts_copy": "必要に応じて、注目したい条件を追加してください。設定した条件に該当する値がレポート上で色付けされます。", + "reports_create_add_alert": "条件を追加", + "reports_create_empty_alerts": "条件はまだありません。通常のレポートの場合は、このままで問題ありません。", + "reports_create_alert_heading": "条件 {index}", + "reports_create_alert_copy": "この条件を設定してください。", + "reports_create_alert_name_placeholder": "高温条件", + "reports_create_alert_metric_key": "項目", + "reports_create_alert_operator": "条件", + "reports_create_alert_minimum": "最小値", + "reports_create_alert_maximum": "最大値", + "reports_create_alert_value": "しきい値", + "reports_create_alert_hex_color": "表示カラー", + "reports_create_alert_points_unit_label": "単位", + "reports_create_alert_points_center_label": "中心値", + "reports_create_alert_points_empty_title": "条件はまだありません。", + "reports_create_alert_points_empty_description": "追加すると、数直線上で条件の位置を確認できます。", + "reports_create_alert_points_invalid_number": "有効な数値を入力してください。", + "reports_create_alert_points_required_field": "{label}を入力してください。", + "reports_create_alert_points_label_with_unit": "{label} ({unit})", + "reports_create_alert_points_invalid_preview_single": "1 件の条件は値が未入力のため、まだ数直線に表示できません。", + "reports_create_alert_points_invalid_preview_multiple": "{count} 件の条件は値が未入力のため、まだ数直線に表示できません。", + "reports_create_alert_points_overlap_preview_single": "1 件の条件が他の条件と重複しています。各条件が数直線上で重ならないように値を調整してください。", + "reports_create_alert_points_overlap_preview_multiple": "{count} 件の条件が他の条件と重複しています。各条件が数直線上で重ならないように値を調整してください。", + "reports_create_alert_points_equal_bounds_warning": "最小値と最大値が同じです。分かりやすくするため、「一致」を使用してください。", + "reports_create_alert_points_condition_equals": "一致 (=)", + "reports_create_alert_points_condition_range": "範囲", + "reports_create_alert_points_condition_less_than": "未満 (<)", + "reports_create_alert_points_condition_less_than_or_equal": "以下 (<=)", + "reports_create_alert_points_condition_greater_than": "超過 (>)", + "reports_create_alert_points_condition_greater_than_or_equal": "以上 (>=)", + "reports_create_alert_points_waiting_for_value": "値の入力待ちです。", + "reports_create_alert_points_waiting_for_threshold": "しきい値の入力待ちです。", + "reports_create_alert_points_range_missing_bounds": "範囲には最小値と最大値の両方が必要です。", + "reports_create_alert_points_description_equals": "= {value} {unit}", + "reports_create_alert_points_description_range": "{min} 〜 {max} {unit}", + "reports_create_alert_points_description_less_than": "< {value} {unit}", + "reports_create_alert_points_description_less_than_or_equal": "<= {value} {unit}", + "reports_create_alert_points_description_greater_than": "> {value} {unit}", + "reports_create_alert_points_description_greater_than_or_equal": ">= {value} {unit}", + "reports_create_alert_points_overlap_error": "{labels} と範囲が重複しています。各条件が数直線上で重ならないように値を調整してください。", + "reports_create_alert_user_label": "ユーザー ID(任意)", + "reports_create_alert_user_placeholder": "必要な場合のみ入力", + "reports_create_advanced_alert_metadata": "システム項目", + "reports_create_recipients_title": "宛先", + "reports_create_recipients_subtitle": "このレポートを受け取る人を設定します。", + "reports_create_recipients_copy": "受け取る人や共有メールアドレスを追加してください。", + "reports_create_add_recipient": "宛先を追加", + "reports_create_empty_recipients": "宛先はまだありません。", + "reports_create_recipient_heading": "宛先 {index}", + "reports_create_recipient_copy": "名前とメールアドレスを入力してください。", + "reports_create_communication_method": "送信方法", + "reports_create_email_label": "メールアドレス", + "reports_create_email_placeholder": "name@company.com", + "reports_create_recipient_name_placeholder": "運用チーム", + "reports_create_recipient_user_label": "ユーザー ID(任意)", + "reports_create_recipient_user_placeholder": "必要な場合のみ入力", + "reports_create_resolved_method": "送信方法", + "reports_create_no_destination": "送信先はまだ設定されていません。", + "reports_create_advanced_recipient_metadata": "システム項目", + "reports_create_preview_title": "確認", + "reports_create_preview_subtitle": "保存する前に内容を確認してください。", + "reports_create_preview_target": "デバイス", + "reports_create_preview_manual_target": "選択中のデバイス", + "reports_create_preview_missing_dev_eui": "デバイスが選択されていません", + "reports_create_preview_valid": "保存できます", + "reports_create_preview_invalid": "確認が必要です", + "reports_create_preview_ready": "送信可能", + "reports_create_preview_issue_single": "1 件の問題", + "reports_create_preview_issue_multiple": "{count} 件の問題", + "reports_create_submit": "保存", + "reports_create_new_report": "新しいレポート", + "reports_create_payload_unreadable": "入力内容を読み取れませんでした。もう一度お試しください。", + "reports_create_failed": "現在レポートを作成できません。しばらくしてからもう一度お試しください。", + "reports_create_operator_gt": "を超える", + "reports_create_operator_gte": "以上", + "reports_create_operator_lt": "未満", + "reports_create_operator_lte": "以下", + "reports_create_operator_eq": "と等しい", + "reports_create_operator_range": "の間", + "reports_create_method_email": "メール", + "reports_create_method_sms": "テキストメッセージ", + "reports_create_method_discord": "Discord", + "reports_schedule_card_title": "データ処理スケジュール", + "reports_schedule_card_subtitle": "時間範囲に基づいてレポートに含めるデータを設定します。", + "reports_schedule_card_copy": "このレポートに含む計測値を制御するため、1つ以上の時間ウィンドウを追加してください。", + "reports_schedule_empty": "データ処理スケジュールはまだありません。追加するとレポートデータを時間でフィルタリングできます。", + "reports_schedule_entry_heading": "スケジュール {index}", + "reports_schedule_day_of_week": "曜日", + "reports_schedule_rule_type": "ルールタイプ", + "reports_schedule_rule_type_include": "含める", + "reports_schedule_rule_type_exclude": "除外", + "reports_schedule_start_time": "開始時刻", + "reports_schedule_end_time": "終了時刻", + "reports_schedule_timezone": "タイムゾーン", + "reports_schedule_crosses_midnight": "深夜をまたぐ", + "reports_schedule_crosses_midnight_description": "時間範囲が翌日にまたがります。", + "reports_schedule_is_enabled": "有効", + "reports_schedule_is_enabled_description": "このスケジュールは有効で適用されます。", + "reports_create_validation_report_name_required": "レポート名を入力してください。", + "reports_create_validation_dev_eui_length": "デバイスを選択してください。", + "reports_create_validation_schedule_dev_eui": "スケジュール {index}: デバイスを選択してください。", + "reports_create_validation_schedule_cadence": "スケジュール {index}: 少なくとも 1 つの配信タイミングを選択してください。", + "reports_create_validation_alert_name": "条件 {index}: 名前を入力してください。", + "reports_create_validation_alert_metric": "条件 {index}: 項目を選択してください。", + "reports_create_validation_alert_operator": "条件 {index}: 条件を選択してください。", + "reports_create_validation_alert_range_required": "条件 {index}: 最小値と最大値の両方を入力してください。", + "reports_create_validation_alert_range_order": "条件 {index}: 最小値は最大値より小さくしてください。", + "reports_create_validation_alert_value": "条件 {index}: しきい値を入力してください。", + "reports_create_validation_alert_hex": "条件 {index}: 正しいカラーコードを入力してください。", + "reports_create_validation_recipient_method": "宛先 {index}: 送信方法を選択してください。", + "reports_create_validation_recipient_destination": "宛先 {index}: メールアドレスを入力してください。", + "reports_create_validation_recipient_email": "宛先 {index}: 正しいメールアドレスを入力してください。", + "permission_level_admin": "管理者", + "permission_level_manager": "マネージャー", + "permission_level_user": "ユーザー", + "permission_level_viewer": "閲覧のみ", + "permission_level_disabled": "無効", + "rule_operator_greater_than": "より大きい (>)", + "rule_operator_less_than": "より小さい (<)", + "rule_operator_equal_to": "等しい (=)", + "rule_operator_greater_or_equal": "以上 (>=)", + "rule_operator_less_or_equal": "以下 (<=)", + "rule_operator_not_equal": "等しくない (!=)", + "rule_notifier_email": "メール", + "rule_notifier_sms": "SMS", + "rule_notifier_push": "プッシュ通知", + "rule_notifier_discord": "Discord", + "rule_send_email": "メール", + "rule_send_sms": "SMS", + "rule_send_both": "両方", + "rule_subject_temperature": "温度 (°C)", + "rule_subject_humidity": "湿度 (%)", + "rule_subject_co2": "CO₂ (ppm)", + "rule_subject_co": "CO (ppm)", + "rule_subject_pressure": "気圧 (hPa)", + "rule_subject_lux": "照度 (Lux)", + "rule_subject_uv_index": "UV 指数", + "rule_subject_wind_speed": "風速", + "rule_subject_wind_direction": "風向", + "rule_subject_rainfall": "降雨量", + "rule_subject_battery_level": "バッテリー残量", + "rule_subject_soil_moisture": "土壌水分", + "rule_subject_electrical_conductivity": "電気伝導率 (EC)", + "rule_subject_ph": "pH", + "rule_subject_water_depth": "水深 (cm)", + "rule_subject_spo2": "SpO₂", + "validation_name_required": "名前は必須です。", + "validation_email_required": "メールアドレスは必須です。", + "validation_user_email_required": "ユーザーメールアドレスは必須です。", + "validation_valid_email_required": "有効なメールアドレスを入力してください。", + "validation_correct_highlighted_fields": "ハイライトされた項目を修正してください。", + "billing_account_status": "アカウント: {status}", + "billing_active_seats": "有効な契約数", + "billing_active_seats_subtitle": "現在課金中のサブスクリプション", + "billing_allow_discount_codes": "割引コードを許可", + "billing_allow_trial": "トライアルを許可", + "billing_api_warnings": "API 警告", + "billing_api_warnings_subtitle": "一部のエンドポイントでエラーが返されました", + "billing_archived": "アーカイブ済み", + "billing_available_products": "利用可能な商品", + "billing_available_products_subtitle": "決済 API から返された商品", + "billing_billed_interval": "{interval} ごとに請求", + "billing_cancel_subscription_body": "{productName} ({id}) を解約しますか?", + "billing_cancel_subscription_title": "サブスクリプションを解約", + "billing_cancel_subscription_warning": "この操作は revoke エンドポイントを呼び出し、このページからは元に戻せません。", + "billing_canceled": "解約済み", + "billing_canceled_subtitle": "過去の解約履歴", + "billing_checkout_redirect_missing": "チェックアウトセッションは作成されましたが、リダイレクト URL が返されませんでした。", + "billing_checkout_session_created": "チェックアウトセッションを作成しました。", + "billing_checkout_session_failed": "チェックアウトセッションを作成できませんでした。", + "billing_checkout_subtitle": "1 つ以上の商品を選択して、ホスト型チェックアウトを起動します", + "billing_checkout_title": "契約とデバイスを購入", + "billing_clear_selection": "選択をクリア", + "billing_column_plan": "プラン", + "billing_column_price": "価格", + "billing_column_renews": "更新日", + "billing_column_started": "開始日", + "billing_column_status": "状態", + "billing_confirm_cancel": "解約を確定", + "billing_custom_pricing": "個別見積もり", + "billing_customer_email": "顧客メールアドレス", + "billing_customer_email_placeholder": "jane@example.com", + "billing_customer_name": "顧客名", + "billing_customer_name_placeholder": "Jane Smith", + "billing_ended": "終了", + "billing_error_products": "商品: {message}", + "billing_error_state": "状態: {message}", + "billing_error_subscriptions": "サブスクリプション: {message}", + "billing_heading": "請求とサブスクリプション", + "billing_include_archived_products": "アーカイブ済み商品を含める", + "billing_include_in_checkout": "チェックアウトに含める", + "billing_keep_subscription": "継続する", + "billing_launch_checkout": "チェックアウトを開始", + "billing_load_products_failed": "請求商品の読み込みに失敗しました。", + "billing_load_state_failed": "サブスクリプション状態の読み込みに失敗しました。", + "billing_load_subscriptions_failed": "サブスクリプションの読み込みに失敗しました。", + "billing_no_product_description": "API から説明が返されませんでした。", + "billing_no_products": "チェックアウトできる商品が見つかりません。", + "billing_one_time": "単発購入", + "billing_open_portal": "請求ポータルを開く", + "billing_optional_checkout_settings": "任意のチェックアウト設定", + "billing_portal_open_failed": "請求ポータルを開けませんでした。", + "billing_portal_opened": "請求ポータルを開きました。", + "billing_portal_redirect_missing": "ポータルセッションは作成されましたが、リダイレクト URL が返されませんでした。", + "billing_portal_session_created": "請求ポータルセッションを作成しました。", + "billing_select_product_before_checkout": "チェックアウト前に少なくとも 1 つの商品を選択してください。", + "billing_selected_count": "{count} 件を選択中", + "billing_status_active": "有効", + "billing_status_canceled": "解約済み", + "billing_status_past_due": "支払い遅延", + "billing_status_trial": "トライアル", + "billing_status_unknown": "不明", + "billing_status_unpaid": "未払い", + "billing_subscription_cancel_failed": "サブスクリプションを解約できませんでした。", + "billing_subscription_canceled": "サブスクリプションを解約しました。", + "billing_subscription_id_required": "サブスクリプション ID は必須です。", + "billing_subscriptions_subtitle": "現在および過去のサブスクリプション記録", + "billing_subscriptions_title": "サブスクリプション", + "billing_subtitle": "プラン購入、アクティブなサブスクリプション、顧客ポータルへのアクセスを管理します。", + "billing_trial_plans": "トライアルプラン", + "billing_trial_plans_subtitle": "トライアル期間中", + "billing_unknown_plan": "不明なプラン", + "common_location": "ロケーション", + "common_weather": "天気", + "dashboard_active_alert_alt": "アクティブなアラート", + "dashboard_column_alerts": "アラート", + "dashboard_column_co2": "CO2 (ppm)", + "dashboard_column_device_name": "デバイス名", + "dashboard_column_humidity": "湿度 (%)", + "dashboard_column_last_seen": "最終受信", + "dashboard_column_location": "ロケーション", + "dashboard_column_soil_humidity": "土壌水分 (%)", + "dashboard_column_soil_temperature": "土壌温度 (°C)", + "dashboard_column_temperature": "温度 (°C)", + "dashboard_loading_more_locations_hint": "残り 5 枚のカードに達すると、さらに 10 件のロケーションを読み込みます。", + "dashboard_no_alerts_alt": "アラートなし", + "dashboard_no_data_yet": "データはまだありません", + "dashboard_no_matching_locations_body": "ダッシュボードのフィルターを調整するか、解除してロケーションをさらに表示してください。", + "dashboard_no_matching_locations_title": "この条件に一致するデバイスはありません", + "dashboard_virtual_scroll": "仮想スクロール", + "demo_paraglide_link": "Paraglide デモ", + "demo_paraglide_sherlock_link": "Sherlock i18n 拡張機能", + "demo_paraglide_vscode_prefix": "VSCode を使っている場合は、", + "demo_paraglide_vscode_suffix": " を入れると i18n の体験が向上します。", + "devices_back_to_detail": "デバイス詳細に戻る", + "devices_back_to_location": "ロケーションに戻る", + "devices_choose_valid_permission_level": "有効な権限レベルを選択してください。", + "devices_create_failed": "デバイスの作成に失敗しました。現在の API ドキュメントではこのエンドポイントは未実装のままです。", + "devices_create_form_note": "このフォームは新しい cw_device および cw_device_owners DTO に直接対応しています。type フィールドは cw_device_type.id と一致する必要があります。", + "devices_create_page_subtitle": "ロケーション {locationId} 用の新しい cw_device ペイロードを準備します", + "devices_create_page_title": "デバイスを作成", + "devices_create_submit": "デバイスを作成", + "devices_csv_export": "CSV", + "devices_current_group_chip": "現在のグループ: {group}", + "devices_dashboard_card_subtitle": "ロケーション {locationName}", + "devices_dashboard_card_title": "デバイス {devEui}", + "devices_dashboard_page_title": "デバイスダッシュボード - {devEui} - CropWatch", + "devices_deployment_section_subtitle": "デバイス行に保存される配置情報とライフサイクル情報です。", + "devices_deployment_section_title": "設置情報", + "devices_dev_eui_invalid": "Device EUI は 16 桁の 16 進文字である必要があります。", + "devices_dev_eui_label": "Device EUI", + "devices_dev_eui_placeholder": "A1:B2:C3:D4:E5:F6:07:08", + "devices_dev_eui_required": "Device EUI は必須です。", + "devices_deveui_chip": "DevEUI {devEui}", + "devices_device_group_length": "デバイスグループは {max} 文字以内で入力してください。", + "devices_device_name_label": "デバイス名", + "devices_device_name_length": "デバイス名は {max} 文字以内で入力してください。", + "devices_device_name_placeholder": "Greenhouse Sensor 1", + "devices_device_name_required": "デバイス名は必須です。", + "devices_device_type_label": "デバイスタイプ", + "devices_device_type_placeholder": "デバイスタイプを選択してください...", + "devices_device_type_required": "デバイスタイプ ID は必須です。", + "devices_download_pdf": "PDF をダウンロード", + "devices_export_dialog_title": "テレメトリデータをエクスポート", + "devices_export_failed": "選択した範囲のテレメトリをエクスポートできませんでした。", + "devices_export_no_data": "選択した日付範囲にテレメトリが見つかりませんでした。", + "devices_export_select_range": "エクスポート範囲を選択", + "devices_export_success": "テレメトリ {count} 行をエクスポートしました。", + "devices_export_timezone_hint": "リクエストとエクスポートのタイムスタンプには {timeZone} を使用します。", + "devices_group_placeholder": "任意のデバイスグループ", + "devices_identity_section_subtitle": "初回 POST ペイロードで使用する主要な cw_device フィールドです。", + "devices_identity_section_title": "デバイス識別情報", + "devices_installed_at_label": "設置日", + "devices_installed_at_placeholder": "設置日を選択", + "devices_invalid_certificate_target": "無効な証明書対象です。", + "devices_invalid_device_id": "無効なデバイス ID です。", + "devices_libellus_api_token_missing": "PRIVATE_LIBELLUS_API_TOKEN が設定されていません。", + "devices_libellus_base_url_missing": "PRIVATE_LIBELLUS_BASE_URL が設定されていません。", + "devices_libellus_product_name_missing": "cw_device_type.model がないため、Libellus の product_name が不明です。", + "devices_libellus_request_failed": "Libellus へのリクエストに失敗しました。", + "devices_load_device_types_failed": "デバイスタイプの読み込みに失敗しました。", + "devices_load_telemetry_failed": "選択した範囲のテレメトリを読み込めませんでした。", + "devices_loading_telemetry": "テレメトリを読み込み中…", + "devices_location_id_label": "ロケーション ID", + "devices_location_id_required": "ロケーション ID は必須です。", + "devices_location_required": "ロケーションは必須です。", + "devices_no_data_yet": "このデバイスにはまだデータがありません。", + "devices_no_device_owners": "このデバイスの所有者は返されませんでした。", + "devices_no_sensor_serial": "このデバイスにはセンサーシリアルが設定されていません。", + "devices_no_sensor_serial_configured": "このデバイスにセンサーシリアルは設定されていません。", + "devices_not_found": "デバイスが見つかりません。", + "devices_owner_email_invalid": "所有者メールアドレスは有効な形式で入力してください。", + "devices_owner_email_required": "所有者メールアドレスは必須です。", + "devices_owner_fallback_name": "ユーザー {index}", + "devices_owner_permissions_subtitle": "更新のみ可能です。追加と削除はここでは意図的に省いています。", + "devices_owner_permissions_title": "デバイス所有者の権限", + "devices_permission_update_rejected": "API が権限更新リクエストを拒否しました。", + "devices_permission_updated_for_email": "{email} の権限を更新しました。", + "devices_range_last_hours": "直近 {hours} 時間", + "devices_range_today_only": "今日のみ", + "devices_save_note_failed": "メモを保存できませんでした。", + "devices_save_note_requires_login": "メモを保存するにはログインが必要です。", + "devices_save_note_success": "メモを保存しました。", + "devices_sensor_certificate_note": "このシリアル番号に対して Sensirion Libellus が返す個別の ISO17025 PDF をダウンロードします。", + "devices_sensor_certificate_requires_login": "センサー証明書をダウンロードするにはログインが必要です。", + "devices_sensor_certificates_subtitle": "設定された各センサーシリアルの Libellus PDF 証明書をダウンロードします。", + "devices_sensor_certificates_title": "デバイスセンサー証明書", + "devices_sensor_one": "センサー 1", + "devices_sensor_serial_chip": "シリアル {serial}", + "devices_sensor_two": "センサー 2", + "devices_settings_page_title": "デバイス設定 | {devEui} | CropWatch", + "devices_settings_subtitle": "cw_device の name と group フィールドを更新します。", + "devices_settings_title": "デバイス設定", + "devices_settings_update_rejected": "API が更新リクエストを拒否しました。", + "devices_settings_updated": "デバイス設定を更新しました。", + "devices_tti_device_id_label": "TTI デバイス ID", + "devices_tti_device_id_placeholder": "例: relay-field-01", + "devices_tti_device_id_invalid": "TTI デバイス ID には小文字、数字、ハイフンのみ使用できます。", + "devices_unassigned_location": "未割り当て", + "devices_unknown_location": "不明", + "devices_update_owner_permission_requires_login": "デバイス所有者の権限を更新するにはログインが必要です。", + "devices_update_permission": "権限を更新", + "devices_update_requires_login": "デバイス設定を更新するにはログインが必要です。", + "devices_relay_controls_requires_permission": "リレーコマンドをキューするにはマネージャー以上の権限が必要です。", + "devices_relay_turn_on": "オンにする", + "devices_relay_turn_off": "オフにする", + "devices_relay_command_queued": "リレー {relay} のコマンドを送信しました。15 秒後に確認します。", + "devices_relay_command_failed": "現在リレーを更新できません。", + "devices_relay_downlink_not_authorized": "このデバイスではリレー制御が許可されていません。", + "devices_relay_downlink_target_not_found": "対象のリレーデバイスが見つかりませんでした。", + "devices_relay_downlink_rate_limited": "リレー要求がレート制限されています。少し待ってから再試行してください。", + "devices_relay_confirmation_pending": "確認待ち", + "devices_relay_waiting_for_confirmation": "コマンドを送信しました。確認を待っています。", + "devices_relay_confirmation_checking_again_in": "再確認まで", + "devices_relay_confirmation_changed": "リレー {relay} が {state} と確認されました。", + "devices_relay_confirmation_mismatch": "リレー {relay} の確認結果が一致しませんでした。API では {state} です。", + "devices_relay_state_unknown": "確認済みのリレーテレメトリーはまだありません。", + "devices_relay_pulse_panel_title": "時間指定パルス制御", + "devices_relay_pulse_description": "リレーを指定した秒数だけオンにし、その後自動で元に戻します。最小パルス時間は 15 秒です。", + "devices_relay_pulse_duration_label": "パルス時間 (秒)", + "devices_relay_pulse_action": "{relay} をパルス", + "devices_relay_pulse_duration_error": "{minSeconds} 秒から {maxSeconds} 秒までの整数を入力してください。", + "devices_update_submit": "デバイスを更新", + "devices_upload_interval_label": "送信間隔 (分)", + "devices_upload_interval_placeholder": "15", + "devices_validation_runs_note": "バリデーションはブラウザとサーバーの両方で実行されます。", + "display_add_note": "メモを追加", + "display_add_note_body": "{createdAt} のテレメトリエントリに、温度 {temperature}°C のメモを追加します。", + "display_add_note_title": "{createdAt} のメモを追加", + "display_current_co2": "現在の CO2", + "display_current_humidity": "現在の湿度", + "display_current_state": "現在の状態", + "display_current_temperature": "現在の温度", + "display_depth": "深さ", + "display_device_data": "デバイスデータ", + "display_enter_note_here": "ここにメモを入力してください...", + "display_generic_view": "汎用表示", + "display_last_updated": "最終更新", + "display_latest_reading": "最新の読み取り", + "display_loading_data": "データを読み込み中…", + "display_loading_power_data": "電力データを取得中…", + "display_no_air_quality_data_selected_range": "選択した範囲の空気品質データはありません。", + "display_no_data": "データなし", + "display_no_data_selected_range": "選択した範囲にデータはありません。", + "display_no_power_data_selected_range": "選択した範囲の電力データはありません。", + "display_no_relay_history": "リレー履歴はありません。", + "display_no_soil_data_selected_range": "選択した範囲の土壌データはありません。", + "display_no_water_data_selected_range": "選択した範囲の水データはありません。", + "display_off": "OFF", + "display_on": "ON", + "display_unknown": "不明", + "display_power": "電力", + "display_power_data": "電力データ", + "display_reading_density": "読み取り密度", + "display_relay_history": "リレー履歴", + "display_relay_one": "リレー 1", + "display_relay_two": "リレー 2", + "display_save_note": "メモを保存", + "display_searchable_sortable": "検索・並べ替え可能", + "display_searchable_sortable_data": "検索・並べ替え可能なデータ", + "display_soil_moisture_temperature": "土壌水分と温度", + "display_soil_telemetry": "土壌テレメトリ", + "display_state_change_log": "状態変更ログ", + "display_telemetry_table": "テレメトリテーブル", + "display_temperature_heatmap": "温度ヒートマップ", + "display_temperature_humidity_chart": "温度と湿度", + "display_time_series": "時系列", + "display_timestamp": "時刻", + "display_view_notes": "メモを表示", + "display_view_notes_title": "{createdAt} のメモを表示", + "display_water_depth": "水深", + "display_water_telemetry": "水テレメトリ", + "display_water_temperature": "水温", + "manifest_description": "インストール可能な CropWatch アプリ体験で、ロケーション、デバイス、ルール、レポートを監視します。", + "manifest_shortcut_dashboard_description": "デバイスダッシュボードを開きます。", + "manifest_shortcut_dashboard_name": "ダッシュボード", + "manifest_shortcut_locations_description": "すべてのロケーションへ直接移動します。", + "manifest_shortcut_locations_name": "ロケーション", + "manifest_shortcut_reports_description": "レポート履歴とスケジュールを開きます。", + "manifest_shortcut_reports_name": "レポート", + "manifest_shortcut_rules_description": "ルールを確認して管理します。", + "manifest_shortcut_rules_name": "ルール", + "traffic_calendar_auth_required": "カレンダーデータの表示には認証済みセッションが必要です。", + "traffic_calendar_fetch_subtitle": "{monthLabel} のカレンダー取得", + "traffic_calendar_total": "交通合計: {count}", + "traffic_current_hour_today": "今日の現在時間帯", + "traffic_current_hour_total": "現在時間帯の合計", + "traffic_daily_traffic_weather": "日別交通量と天気", + "traffic_daily_weather": "日次天気", + "traffic_device_coordinates_unavailable": "デバイス座標を利用できません", + "traffic_download_hourly_csv": "時間別 CSV をダウンロード", + "traffic_fetching_traffic_data": "交通データを取得中...", + "traffic_hour": "時間", + "traffic_hourly_traffic": "時間別交通量", + "traffic_hourly_traffic_subtitle": "今日の時間ごとの集計", + "traffic_lat_lng_label": "緯度/経度 {label}", + "traffic_latest_available_hour_today": "今日の最新利用可能時間帯", + "traffic_load_month_failed": "表示中の月の交通データを読み込めませんでした。", + "traffic_loading_current_traffic": "現在の交通量を読み込み中...", + "traffic_loading_visible_month": "表示中の月を読み込み中...", + "traffic_loading_weather": "天気を読み込み中...", + "traffic_low_temperature": "最低 {value}", + "traffic_month_traffic_days": "月間交通データ {count} 日", + "traffic_no_class_data_recorded": "分類データは記録されていません。", + "traffic_no_data_this_month": "この月の交通データは記録されていません", + "traffic_no_data_today": "今日の交通データはありません。", + "traffic_no_traffic_today": "今日は交通データがありません", + "traffic_precip_summary": "降水量 {value} mm", + "traffic_preparing_csv": "CSV を準備中...", + "traffic_raw_records_count": "生レコード {count} 件", + "traffic_samples": "サンプル", + "traffic_samples_count": "サンプル {count} 件", + "traffic_selected_period": "選択期間", + "traffic_today_only": "今日のみ", + "traffic_today_via_coordinates": "デバイス座標に基づく今日の天気", + "traffic_total_traffic": "交通量合計", + "traffic_tracked_hours_count": "追跡時間 {count} 件", + "traffic_unavailable": "利用不可", + "traffic_weather_days_count": "天気日数 {count} 日", + "traffic_weather_requires_coordinates": "天気の取得にはデバイスの緯度と経度が必要です。", + "traffic_weather_today_failed": "今日の天気を読み込めませんでした。", + "traffic_weather_unavailable": "天気を利用できません", + "traffic_wind_summary": "風速 {value} km/h", + "weather_clear": "快晴", + "weather_clear_sky": "澄んだ空", + "weather_cloudy": "曇り", + "weather_drizzle": "霧雨", + "weather_fog": "霧", + "weather_foggy": "霧が出ています", + "weather_mostly_clear": "ほぼ晴れ", + "weather_mostly_clear_description": "ほぼ晴れ", + "weather_overcast": "どんより曇り", + "weather_partly_cloudy": "晴れ時々曇り", + "weather_partly_cloudy_description": "一部曇り", + "weather_rain": "雨", + "weather_snow": "雪", + "weather_storm": "嵐", + "weather_thunderstorm": "雷雨", + "display_temperature": "現在の温度", + "display_temperature_humidity": "現在の湿度", + "stat_min": "最小", + "stat_avg": "平均", + "stat_max": "最大", + "stat_count": "件数", + "stat_median": "中央値", + "stat_stdDev": "標準偏差", + "stat_range": "範囲", + "stat_aboveAvg": "平均以上", + "stat_belowAvg": "平均以下", + "stat_atAvg": "平均", + "stat_expand": "展開", + "stat_collapse": "折りたたむ" +} diff --git a/package.json b/package.json index df368738..5ab84fe6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "svdesignpattern", + "name": "cropwatch", "private": true, "version": "0.0.1", "type": "module", @@ -7,94 +7,53 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "prebuild": "node ./scripts/build-info.js", - "prestart:build": "node ./scripts/build-info.js", - "prepare": "svelte-kit sync && pnpm husky install", + "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", + "style:audit": "node ./scripts/style-audit.mjs", "lint": "prettier --check . && eslint .", + "format": "prettier --write .", "test:unit": "vitest", "test": "npm run test:unit -- --run && npm run test:e2e", - "test:e2e": "playwright test", - "postinstall": "husky install" - }, - "lint-staged": { - "**/*.{ts,js,svelte}": [ - "prettier --write", - "git add" - ] + "test:e2e": "playwright test" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.37.0", - "@playwright/test": "^1.56.0", - "@sveltejs/adapter-vercel": "^5.10.3", - "@sveltejs/kit": "^2.46.5", - "@sveltejs/vite-plugin-svelte": "^5.1.1", - "@tailwindcss/forms": "^0.5.10", + "@eslint/compat": "^2.0.2", + "@eslint/js": "^9.39.2", + "@fontsource/fira-mono": "^5.2.7", + "@inlang/paraglide-js": "^2.10.0", + "@neoconfetti/svelte": "^2.2.2", + "@playwright/test": "^1.58.2", + "@sveltejs/adapter-vercel": "^6.3.2", + "@sveltejs/kit": "^2.52.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.14", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/svelte": "^5.2.8", - "@types/d3": "^7.4.3", - "@types/luxon": "^3.7.1", - "@types/pdfkit": "^0.17.3", - "@types/swagger-ui": "^5.21.1", - "@vite-pwa/sveltekit": "^1.0.0", - "eslint": "^9.37.0", + "@tailwindcss/vite": "^4.2.0", + "@types/node": "^22.19.11", + "@vitest/browser-playwright": "^4.0.18", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.12.4", - "globals": "^16.4.0", - "husky": "^8.0.3", - "jsdom": "^26.1.0", - "lint-staged": "^16.2.4", - "prettier": "^3.6.2", - "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.6.14", - "supabase": "^2.51.0", - "svelte": "^5.39.11", - "svelte-check": "^4.3.3", - "tailwindcss": "^4.1.14", + "eslint-plugin-svelte": "^3.15.0", + "globals": "^17.3.0", + "jwt-decode": "^4.0.0", + "playwright": "^1.58.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.53.0", + "svelte-check": "^4.4.1", + "tailwindcss": "^4.2.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.0", - "vite": "^6.3.6", - "vite-plugin-commonjs": "^0.10.4", - "vitest": "^3.2.4" + "typescript-eslint": "^8.56.0", + "vite": "^7.3.1", + "vite-plugin-devtools-json": "^1.0.0", + "vitest": "^4.0.18", + "vitest-browser-svelte": "^2.0.2" }, + "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@internationalized/date": "^3.10.0", - "@mdi/js": "^7.4.47", - "@stencil/store": "^2.2.0", - "@stripe/stripe-js": "^7.9.0", - "@supabase/ssr": "^0.6.1", - "@supabase/supabase-js": "^2.75.0", - "@types/lodash": "^4.17.20", - "apexcharts": "^4.7.0", - "bits-ui": "^1.8.0", - "bufferutil": "^4.0.9", - "canvas": "^3.2.0", - "chart.js": "^4.5.1", - "d3": "^7.9.0", - "d3-axis": "^3.0.0", - "d3-scale": "^4.0.2", - "d3-selection": "^3.0.0", - "jspdf": "^3.0.3", - "leaflet": "2.0.0-alpha", - "lodash": "^4.17.21", - "luxon": "^3.7.2", - "pdfkit": "^0.17.2", - "stripe": "^18.5.0", - "svelte-i18n": "^4.0.1", - "swagger-ui": "^5.29.4", - "utf-8-validate": "^6.0.5" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "canvas", - "esbuild", - "svelte-preprocess" - ] - }, - "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd" + "@cropwatchdevelopment/cwui": "0.1.73", + "@supabase/supabase-js": "^2.98.0" + } } diff --git a/playwright.config.ts b/playwright.config.ts index f6c81af8..9fad7295 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,8 +2,28 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { - command: 'npm run build && npm run preview', - port: 4173 + command: 'node scripts/e2e-stack.mjs', + port: 4173, + reuseExistingServer: !process.env.CI }, - testDir: 'e2e' + testDir: 'e2e', + projects: [ + { + name: 'chromium', + use: { + browserName: 'chromium' + } + }, + { + name: 'webkit-mobile', + use: { + browserName: 'webkit', + viewport: { width: 390, height: 844 }, + isMobile: true + } + } + ], + use: { + baseURL: 'http://127.0.0.1:4173' + } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e632af50..4fb07269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,1093 +8,482 @@ importers: .: dependencies: - '@internationalized/date': - specifier: ^3.10.0 - version: 3.10.0 - '@mdi/js': - specifier: ^7.4.47 - version: 7.4.47 - '@stencil/store': - specifier: ^2.2.0 - version: 2.2.0(@stencil/core@4.35.1) - '@stripe/stripe-js': - specifier: ^7.9.0 - version: 7.9.0 - '@supabase/ssr': - specifier: ^0.6.1 - version: 0.6.1(@supabase/supabase-js@2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)) + '@cropwatchdevelopment/cwui': + specifier: 0.1.73 + version: 0.1.73(svelte@5.53.0) '@supabase/supabase-js': - specifier: ^2.75.0 - version: 2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) - '@types/lodash': - specifier: ^4.17.20 - version: 4.17.20 - apexcharts: - specifier: ^4.7.0 - version: 4.7.0 - bits-ui: - specifier: ^1.8.0 - version: 1.8.0(svelte@5.39.11) - bufferutil: - specifier: ^4.0.9 - version: 4.0.9 - canvas: - specifier: ^3.2.0 - version: 3.2.0 - chart.js: - specifier: ^4.5.1 - version: 4.5.1 - d3: - specifier: ^7.9.0 - version: 7.9.0 - d3-axis: - specifier: ^3.0.0 - version: 3.0.0 - d3-scale: - specifier: ^4.0.2 - version: 4.0.2 - d3-selection: - specifier: ^3.0.0 - version: 3.0.0 - jspdf: - specifier: ^3.0.3 - version: 3.0.3 - leaflet: - specifier: 2.0.0-alpha - version: 2.0.0-alpha - lodash: - specifier: ^4.17.21 - version: 4.17.21 - luxon: - specifier: ^3.7.2 - version: 3.7.2 - pdfkit: - specifier: ^0.17.2 - version: 0.17.2 - stripe: - specifier: ^18.5.0 - version: 18.5.0(@types/node@24.7.2) - svelte-i18n: - specifier: ^4.0.1 - version: 4.0.1(svelte@5.39.11) - swagger-ui: - specifier: ^5.29.4 - version: 5.29.4 - utf-8-validate: - specifier: ^6.0.5 - version: 6.0.5 + specifier: ^2.98.0 + version: 2.98.0 devDependencies: '@eslint/compat': - specifier: ^1.4.0 - version: 1.4.0(eslint@9.37.0(jiti@2.6.1)) + specifier: ^2.0.2 + version: 2.0.2(eslint@9.39.2(jiti@2.6.1)) '@eslint/js': - specifier: ^9.37.0 - version: 9.37.0 + specifier: ^9.39.2 + version: 9.39.2 + '@fontsource/fira-mono': + specifier: ^5.2.7 + version: 5.2.7 + '@inlang/paraglide-js': + specifier: ^2.10.0 + version: 2.15.0 + '@neoconfetti/svelte': + specifier: ^2.2.2 + version: 2.2.2(svelte@5.53.0) '@playwright/test': - specifier: ^1.56.0 - version: 1.56.0 + specifier: ^1.58.2 + version: 1.58.2 '@sveltejs/adapter-vercel': - specifier: ^5.10.3 - version: 5.10.3(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(rollup@2.79.2) + specifier: ^6.3.2 + version: 6.3.2(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(rollup@4.57.1) '@sveltejs/kit': - specifier: ^2.46.5 - version: 2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) + specifier: ^2.52.2 + version: 2.52.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) '@sveltejs/vite-plugin-svelte': - specifier: ^5.1.1 - version: 5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) + specifier: ^6.2.4 + version: 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) '@tailwindcss/forms': - specifier: ^0.5.10 - version: 0.5.10(tailwindcss@4.1.14) + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@4.2.0) '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.1.14) + version: 0.5.19(tailwindcss@4.2.0) '@tailwindcss/vite': - specifier: ^4.1.14 - version: 4.1.14(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - '@testing-library/jest-dom': - specifier: ^6.9.1 - version: 6.9.1 - '@testing-library/svelte': - specifier: ^5.2.8 - version: 5.2.8(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/node@24.7.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - '@types/d3': - specifier: ^7.4.3 - version: 7.4.3 - '@types/luxon': - specifier: ^3.7.1 - version: 3.7.1 - '@types/pdfkit': - specifier: ^0.17.3 - version: 0.17.3 - '@types/swagger-ui': - specifier: ^5.21.1 - version: 5.21.1 - '@vite-pwa/sveltekit': - specifier: ^1.0.0 - version: 1.0.0(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(workbox-build@7.3.0)(workbox-window@7.3.0) + specifier: ^4.2.0 + version: 4.2.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/node': + specifier: ^22.19.11 + version: 22.19.11 + '@vitest/browser-playwright': + specifier: ^4.0.18 + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18) eslint: - specifier: ^9.37.0 - version: 9.37.0(jiti@2.6.1) + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.37.0(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: - specifier: ^3.12.4 - version: 3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11) + specifier: ^3.15.0 + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.53.0) globals: - specifier: ^16.4.0 - version: 16.4.0 - husky: - specifier: ^8.0.3 - version: 8.0.3 - jsdom: - specifier: ^26.1.0 - version: 26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5) - lint-staged: - specifier: ^16.2.4 - version: 16.2.4 + specifier: ^17.3.0 + version: 17.3.0 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + playwright: + specifier: ^1.58.2 + version: 1.58.2 prettier: - specifier: ^3.6.2 - version: 3.6.2 + specifier: ^3.8.1 + version: 3.8.1 prettier-plugin-svelte: - specifier: ^3.4.0 - version: 3.4.0(prettier@3.6.2)(svelte@5.39.11) + specifier: ^3.5.0 + version: 3.5.0(prettier@3.8.1)(svelte@5.53.0) prettier-plugin-tailwindcss: - specifier: ^0.6.14 - version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.39.11))(prettier@3.6.2) - supabase: - specifier: ^2.51.0 - version: 2.51.0 + specifier: ^0.7.2 + version: 0.7.2(prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0))(prettier@3.8.1) svelte: - specifier: ^5.39.11 - version: 5.39.11 + specifier: ^5.53.0 + version: 5.53.0 svelte-check: - specifier: ^4.3.3 - version: 4.3.3(picomatch@4.0.3)(svelte@5.39.11)(typescript@5.9.3) + specifier: ^4.4.1 + version: 4.4.1(picomatch@4.0.3)(svelte@5.53.0)(typescript@5.9.3) tailwindcss: - specifier: ^4.1.14 - version: 4.1.14 + specifier: ^4.2.0 + version: 4.2.0 typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.46.0 - version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^6.3.6 - version: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - vite-plugin-commonjs: - specifier: ^0.10.4 - version: 0.10.4 + specifier: ^7.3.1 + version: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) + vite-plugin-devtools-json: + specifier: ^1.0.0 + version: 1.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.7.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.11)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1) + vitest-browser-svelte: + specifier: ^2.0.2 + version: 2.0.2(svelte@5.53.0)(vitest@4.0.18) packages: - '@adobe/css-tools@4.4.4': - resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - - '@apideck/better-ajv-errors@0.3.6': - resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} - engines: {node: '>=10'} - peerDependencies: - ajv: '>=8' - - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.28.4': - resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.4': - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.28.3': - resolution: {integrity: sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-create-regexp-features-plugin@7.27.1': - resolution: {integrity: sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-define-polyfill-provider@0.6.5': - resolution: {integrity: sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.27.1': - resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-remap-async-to-generator@7.27.1': - resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-replace-supers@7.27.1': - resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-wrap-function@7.28.3': - resolution: {integrity: sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': - resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': - resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': - resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': - resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.13.0 - - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3': - resolution: {integrity: sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': - resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-assertions@7.27.1': - resolution: {integrity: sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-unicode-sets-regex@7.18.6': - resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-arrow-functions@7.27.1': - resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-async-generator-functions@7.28.0': - resolution: {integrity: sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-async-to-generator@7.27.1': - resolution: {integrity: sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-block-scoped-functions@7.27.1': - resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-block-scoping@7.28.4': - resolution: {integrity: sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-class-properties@7.27.1': - resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-class-static-block@7.28.3': - resolution: {integrity: sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.12.0 - - '@babel/plugin-transform-classes@7.28.4': - resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-computed-properties@7.27.1': - resolution: {integrity: sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-destructuring@7.28.0': - resolution: {integrity: sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-dotall-regex@7.27.1': - resolution: {integrity: sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-duplicate-keys@7.27.1': - resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-dynamic-import@7.27.1': - resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-explicit-resource-management@7.28.0': - resolution: {integrity: sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-exponentiation-operator@7.27.1': - resolution: {integrity: sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-export-namespace-from@7.27.1': - resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-for-of@7.27.1': - resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-function-name@7.27.1': - resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-json-strings@7.27.1': - resolution: {integrity: sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-literals@7.27.1': - resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-logical-assignment-operators@7.27.1': - resolution: {integrity: sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-member-expression-literals@7.27.1': - resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-amd@7.27.1': - resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.27.1': - resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-systemjs@7.27.1': - resolution: {integrity: sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-umd@7.27.1': - resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-new-target@7.27.1': - resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': - resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-numeric-separator@7.27.1': - resolution: {integrity: sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-object-rest-spread@7.28.4': - resolution: {integrity: sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-object-super@7.27.1': - resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-optional-catch-binding@7.27.1': - resolution: {integrity: sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-optional-chaining@7.27.1': - resolution: {integrity: sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-parameters@7.27.7': - resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-private-methods@7.27.1': - resolution: {integrity: sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-private-property-in-object@7.27.1': - resolution: {integrity: sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-property-literals@7.27.1': - resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-regenerator@7.28.4': - resolution: {integrity: sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-regexp-modifiers@7.27.1': - resolution: {integrity: sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-reserved-words@7.27.1': - resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-shorthand-properties@7.27.1': - resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-spread@7.27.1': - resolution: {integrity: sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-sticky-regex@7.27.1': - resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-template-literals@7.27.1': - resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typeof-symbol@7.27.1': - resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-escapes@7.27.1': - resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-property-regex@7.27.1': - resolution: {integrity: sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-regex@7.27.1': - resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-sets-regex@7.27.1': - resolution: {integrity: sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/preset-env@7.28.3': - resolution: {integrity: sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-modules@0.1.6-no-external-plugins': - resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} - peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 - - '@babel/runtime-corejs3@7.28.4': - resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} - engines: {node: '>=6.9.0'} - - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.4': - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} - engines: {node: '>=6.9.0'} - - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} + '@cropwatchdevelopment/cwui@0.1.73': + resolution: {integrity: sha512-OjRlY4ke0sv+gaVO3EpBSM6Uq8SQnyecuEv39auvAnDOiFzB0jp2ec7taKmZRZ3ztW1R5c9HVk5Wa0fVmGQtwQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.73/27e6d1ed76c3e8faeb3d2ed8dbdb3c0888b06a29} peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 + svelte: ^5.0.0 - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} - - '@esbuild/aix-ppc64@0.19.12': - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.10': - resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.19.12': - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.10': - resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.19.12': - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.10': - resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.19.12': - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.10': - resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.19.12': - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.10': - resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.19.12': - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.10': - resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.19.12': - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.10': - resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.19.12': - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.10': - resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.19.12': - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.10': - resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.19.12': - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.10': - resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.19.12': - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.10': - resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.19.12': - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.10': - resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.19.12': - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.10': - resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.19.12': - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.10': - resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.19.12': - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.10': - resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.19.12': - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.10': - resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.19.12': - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.10': - resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.10': - resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.19.12': - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.10': - resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.10': - resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.19.12': - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.10': - resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.10': - resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.19.12': - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.10': - resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.19.12': - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.10': - resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.19.12': - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.10': - resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.19.12': - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.10': - resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.4.0': - resolution: {integrity: sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/compat@2.0.2': + resolution: {integrity: sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: - eslint: ^8.40 || 9 + eslint: ^8.40 || 9 || 10 peerDependenciesMeta: eslint: optional: true - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.4.0': - resolution: {integrity: sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.16.0': - resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@9.37.0': - resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.4.0': - resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - - '@formatjs/ecma402-abstract@2.3.6': - resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} - - '@formatjs/fast-memoize@2.2.7': - resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} - - '@formatjs/icu-messageformat-parser@2.11.4': - resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} - - '@formatjs/icu-skeleton-parser@1.8.16': - resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@formatjs/intl-localematcher@0.6.2': - resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@fontsource/fira-mono@5.2.7': + resolution: {integrity: sha512-wYrAn6i3nH6luqQBZxtWUpl4UTUvs9AEbEeZxksPMwIqyjRRaxHTNW3c2VfM50gabS2IS7pT8lVWS2USB4ukYA==} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1112,12 +501,16 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@internationalized/date@3.10.0': - resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + '@inlang/paraglide-js@2.15.0': + resolution: {integrity: sha512-2ZOa9nssVn4tjkKskqb88KP5A7cTIjo8AiM9xnPvH+vBhRIRenO+ftAbVOHhHcHjcFxy2QFcOfBAH/Cw1LIsUg==} + hasBin: true + + '@inlang/recommend-sherlock@0.2.1': + resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} + '@inlang/sdk@2.8.0': + resolution: {integrity: sha512-w1jysvUDTMgCaONklIgOJAp9dUDl0UhLbsdqfWEwY/GIqoc9IwpuHsrP3pzC+h3DfOpkMMDnDkTpPv8kIZ98iA==} + engines: {node: '>=18.0.0'} '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} @@ -1133,90 +526,37 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kurkle/color@0.3.4': - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lix-js/sdk@0.4.7': + resolution: {integrity: sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==} + engines: {node: '>=18'} + + '@lix-js/server-protocol-schema@0.1.1': + resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@mapbox/node-pre-gyp@2.0.0': - resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} hasBin: true - '@mdi/js@7.4.47': - resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@neoconfetti/svelte@2.2.2': + resolution: {integrity: sha512-E7xCFVEEm5Ctnj2udTJy1b9oaTvjz1zi1mYdEtE8rB5BVwq6kHisosDS+zdWN5PMfEMjtbsOV9Cl6tsNSAD1sA==} + peerDependencies: + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 - '@playwright/test@1.56.0': - resolution: {integrity: sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@rollup/plugin-babel@5.3.1': - resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} - engines: {node: '>= 10.0.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@types/babel__core': ^7.1.9 - rollup: ^1.20.0||^2.0.0 - peerDependenciesMeta: - '@types/babel__core': - optional: true - - '@rollup/plugin-node-resolve@15.3.1': - resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-replace@2.4.2': - resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} - peerDependencies: - rollup: ^1.20.0 || ^2.0.0 - - '@rollup/plugin-terser@0.4.4': - resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/pluginutils@3.1.0': - resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} - engines: {node: '>= 8.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0 - '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -1226,435 +566,271 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.52.4': - resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.52.4': - resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.34.9': - resolution: {integrity: sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-arm64@4.52.4': - resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.34.9': - resolution: {integrity: sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==} + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.52.4': - resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.52.4': - resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.52.4': - resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.52.4': - resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.52.4': - resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.34.9': - resolution: {integrity: sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.52.4': - resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.34.9': - resolution: {integrity: sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==} - cpu: [arm64] + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.52.4': - resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} - cpu: [arm64] + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.52.4': - resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} - cpu: [loong64] + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.52.4': - resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.52.4': - resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.52.4': - resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.52.4': - resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.34.9': - resolution: {integrity: sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.52.4': - resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.34.9': - resolution: {integrity: sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==} + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.52.4': - resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} cpu: [x64] - os: [linux] + os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.52.4': - resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.34.9': - resolution: {integrity: sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-arm64-msvc@4.52.4': - resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.52.4': - resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.52.4': - resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.34.9': - resolution: {integrity: sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==} + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.52.4': - resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} cpu: [x64] os: [win32] - '@scarf/scarf@1.4.0': - resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.31.28': + resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - - '@stencil/core@4.35.1': - resolution: {integrity: sha512-u65m3TbzOtpn679gUV4Yvi8YpInhRJ62js30a7YtXief9Ej/vzrhwDE22U0w4DMWJOYwAsJl133BUaZkWwnmzg==} - engines: {node: '>=16.0.0', npm: '>=7.10.0'} + '@sqlite.org/sqlite-wasm@3.48.0-build4': + resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} hasBin: true - '@stencil/store@2.2.0': - resolution: {integrity: sha512-+Ub0n3ghwxjXSGrLJDt6SIKJQhB4ch61KyzNkleIN5IADdhwvT8/9SjwU67hhSEoBTpQ81bVNlhuHSHS97iHbA==} - engines: {node: '>=18.0.0', npm: '>=6.0.0'} - peerDependencies: - '@stencil/core': '>=2.0.0 || >=3.0.0 || >= 4.0.0-beta.0 || >= 4.0.0' - - '@stripe/stripe-js@7.9.0': - resolution: {integrity: sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==} - engines: {node: '>=12.16'} - - '@supabase/auth-js@2.75.0': - resolution: {integrity: sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/functions-js@2.75.0': - resolution: {integrity: sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==} - - '@supabase/node-fetch@2.6.15': - resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} - engines: {node: 4.x || >=6.0.0} - - '@supabase/postgrest-js@2.75.0': - resolution: {integrity: sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==} + '@supabase/auth-js@2.98.0': + resolution: {integrity: sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==} + engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.75.0': - resolution: {integrity: sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==} + '@supabase/functions-js@2.98.0': + resolution: {integrity: sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==} + engines: {node: '>=20.0.0'} - '@supabase/ssr@0.6.1': - resolution: {integrity: sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==} - peerDependencies: - '@supabase/supabase-js': ^2.43.4 + '@supabase/postgrest-js@2.98.0': + resolution: {integrity: sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==} + engines: {node: '>=20.0.0'} - '@supabase/storage-js@2.75.0': - resolution: {integrity: sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==} + '@supabase/realtime-js@2.98.0': + resolution: {integrity: sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==} + engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.75.0': - resolution: {integrity: sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==} + '@supabase/storage-js@2.98.0': + resolution: {integrity: sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==} + engines: {node: '>=20.0.0'} - '@surma/rollup-plugin-off-main-thread@2.2.3': - resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + '@supabase/supabase-js@2.98.0': + resolution: {integrity: sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==} + engines: {node: '>=20.0.0'} - '@sveltejs/acorn-typescript@1.0.6': - resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} peerDependencies: acorn: ^8.9.0 - '@sveltejs/adapter-vercel@5.10.3': - resolution: {integrity: sha512-fW2ZhMiOrUKsJJhiB4ift9sYDSFWgvH3N22cjf8ukOyWgHolb9SmSS3owr+nHQNlgTEAdy4eIr4Fnasw3nkxTw==} + '@sveltejs/adapter-vercel@6.3.2': + resolution: {integrity: sha512-b9zkpCDWb5iuNtSgSabd2jBekFk+2WaaH454bC5Kl1dH8Fxt9SP4k4nug/Yek+sLvceK8SKcuuRIFuI91GJ+wA==} + engines: {node: '>=20.0'} peerDependencies: '@sveltejs/kit': ^2.4.0 - '@sveltejs/kit@2.46.5': - resolution: {integrity: sha512-7TSvMrCdmig5TMyYDW876C5FljhA0wlGixtvASCiqUqtLfmyEEpaysXjC7GhR5mWcGRrCGF+L2Bl1eEaW1wTCA==} + '@sveltejs/kit@2.52.2': + resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.0.0 '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 peerDependenciesMeta: '@opentelemetry/api': optional: true + typescript: + optional: true - '@sveltejs/vite-plugin-svelte-inspector@4.0.1': - resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22} + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: - '@sveltejs/vite-plugin-svelte': ^5.0.0 + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 svelte: ^5.0.0 - vite: ^6.0.0 + vite: ^6.3.0 || ^7.0.0 - '@sveltejs/vite-plugin-svelte@5.1.1': - resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22} + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: svelte: ^5.0.0 - vite: ^6.0.0 - - '@svgdotjs/svg.draggable.js@3.0.6': - resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==} - peerDependencies: - '@svgdotjs/svg.js': ^3.2.4 - - '@svgdotjs/svg.filter.js@3.0.9': - resolution: {integrity: sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==} - engines: {node: '>= 0.8.0'} - - '@svgdotjs/svg.js@3.2.5': - resolution: {integrity: sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==} - - '@svgdotjs/svg.resize.js@2.0.5': - resolution: {integrity: sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==} - engines: {node: '>= 14.18'} - peerDependencies: - '@svgdotjs/svg.js': ^3.2.4 - '@svgdotjs/svg.select.js': ^4.0.1 - - '@svgdotjs/svg.select.js@4.0.3': - resolution: {integrity: sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==} - engines: {node: '>= 14.18'} - peerDependencies: - '@svgdotjs/svg.js': ^3.2.4 - - '@swagger-api/apidom-ast@1.0.0-beta.50': - resolution: {integrity: sha512-uUBUm6J6KlyKppyfS7DIW37De6oyMVIpHYmaNV3YAaDMuRMov5KHHWXKbqWlI+l493OljOcXEqDIPeLzm6B5PQ==} - - '@swagger-api/apidom-core@1.0.0-beta.50': - resolution: {integrity: sha512-9N7ySdyzx/3kUnprAi63GQNt+Kq8VUvErwDgPcMRAsZX8jUhk9KLJ9N0fup4mWm6+xGs0JH35wxBxnanS6aiqw==} - - '@swagger-api/apidom-error@1.0.0-beta.50': - resolution: {integrity: sha512-vdpi2nRVcxXLGc68JPNwTcKrCKl8PnOEPuykZSxeNbDKnZY80APbsoLDX+1gdRgafK/7k5XdsBkpDQscsTkDng==} - - '@swagger-api/apidom-json-pointer@1.0.0-beta.50': - resolution: {integrity: sha512-2TgFKHlZ/SlnTZzY7EwE8xx5Pr2BYePX52xZJFqWnueSAIcCcsrqZeazWIAaDe/gXd47CDqU95nDChMECERspA==} - - '@swagger-api/apidom-ns-api-design-systems@1.0.0-beta.50': - resolution: {integrity: sha512-VsJLgBHYEBx0tyqUVrOhK4YSknn2mSbxigy92hV0Xn0oXuGibY1NW+2yIE9wwgjutCgtmo0Ovl9pe+cagzSh4w==} - - '@swagger-api/apidom-ns-arazzo-1@1.0.0-beta.50': - resolution: {integrity: sha512-DsKi4um67e5RMVgEzbiYMa4Z5VcwFncrLF1blxU0Gzc29LB49vc/5NDXgJaJZ7F7rYWNWJ+EqIsbVjEKAiehHg==} - - '@swagger-api/apidom-ns-asyncapi-2@1.0.0-beta.50': - resolution: {integrity: sha512-+iIGnyvdsAlqIIuKzWSXsgwva+g7QkxZ2AkB3jBVIMqLp1AANhHaJz2nBTlgg3PYpcMpek3EutcJWiSTdaaQEQ==} - - '@swagger-api/apidom-ns-json-schema-2019-09@1.0.0-beta.50': - resolution: {integrity: sha512-QP6DuthV8ZWQnthYbPEVikK5rTN4T5lhnAnmO1v6zOCS9B1heKCFcIYgBhcqCnuZ0Tt8kGOfLyqGMb57lPkCdw==} - - '@swagger-api/apidom-ns-json-schema-2020-12@1.0.0-beta.50': - resolution: {integrity: sha512-ZaqrtZEXUx35x66ND8sc5vf1sIuWPERA15EdRHeca56E09RnjZMUHkiDvdx78165h31QmM67YLi04zEBYhQS0g==} - - '@swagger-api/apidom-ns-json-schema-draft-4@1.0.0-beta.50': - resolution: {integrity: sha512-aqCwW+iuN7RokH10vDp/eEwlrT4LAlHGy1pLzAS9aFVJyUutfm0I4fxLfddOKD2yd04z858zhLwOVSo4BjrLHg==} - - '@swagger-api/apidom-ns-json-schema-draft-6@1.0.0-beta.50': - resolution: {integrity: sha512-trF1TZZ79WJOjQw3C1Y7wcqNMxxgHMZtJW2/tP5MwII1hqsExGzmGyUuNlVuSC9k9v/9sCj85hQlJ4TW6HFciQ==} - - '@swagger-api/apidom-ns-json-schema-draft-7@1.0.0-beta.50': - resolution: {integrity: sha512-g9VscnMwjPUYCfR6UxUwsLiIKnyXy2W28J+zN0rbijoSEtUdakcrxwdPhqwgJZHPci8NHNE8574zaocqKBiqSg==} - - '@swagger-api/apidom-ns-openapi-2@1.0.0-beta.50': - resolution: {integrity: sha512-1kp1BlLFcv6If+hLbJ1wbrRQUpiRHEBwrdNjgflSgK8IIFH9/Q3LWMFsms8lmzQtpOUvPuXBlmMAmpQ1cAljZQ==} - - '@swagger-api/apidom-ns-openapi-3-0@1.0.0-beta.50': - resolution: {integrity: sha512-I4GHyNILNxDsYKYeG1+ZA3rnfU1RAYtNp3dA+G8LCX5AB/2N7dT2VPK8HS4cj9m3ZVz7dl1o+X6tpaJIN5kDsA==} - - '@swagger-api/apidom-ns-openapi-3-1@1.0.0-beta.50': - resolution: {integrity: sha512-kxwuaFl1kQddk/RBS5Mz3rE/6v5mXggqhzVwDBObGjgkRmDRVF5nUalziBRNg6A3NcpYbsjNMU/OCA1JihFkrg==} - - '@swagger-api/apidom-parser-adapter-api-design-systems-json@1.0.0-beta.50': - resolution: {integrity: sha512-CGH2/BXLR8bSXUXUgVt5az9HSQanvU5YpKke2R9laDnO+9b4w02LhkUHiLtPMX6A+mk9Cax+ktTh6mlSKzq7pA==} - - '@swagger-api/apidom-parser-adapter-api-design-systems-yaml@1.0.0-beta.50': - resolution: {integrity: sha512-fcw5FLKgvaPNVhkS1H6mg0uqPO8+R8jR+eFBSZRqm8IQAlzyxE/I5tUCNpMwqQQPShV3H014EHPJjfnihfZQKw==} - - '@swagger-api/apidom-parser-adapter-arazzo-json-1@1.0.0-beta.50': - resolution: {integrity: sha512-cZWT5Ch0aPxc3uneuAc2lmGt29PTD9SYgkSYrL7EY+ooJetwjSqeLIVw6oBJHjUWtJqyKGdryyylpqNFsHV6qw==} - - '@swagger-api/apidom-parser-adapter-arazzo-yaml-1@1.0.0-beta.50': - resolution: {integrity: sha512-wnwuzOzB3z141+6XYFs/1R1BKwEFmQjmITEvXHF3r0QzH3ayS+mOkp2NfYN4skDBLOoBCXw5ezr8v9qthW9Ojg==} + vite: ^6.3.0 || ^7.0.0 - '@swagger-api/apidom-parser-adapter-asyncapi-json-2@1.0.0-beta.50': - resolution: {integrity: sha512-mXgT9K+cATsRJulOHveQwZJ3VlqQ11Ashme4Xk1XdUSmavEkKsnmokkz5oo57KwyqaFCQnuI0MtpMkwWZK7zVA==} - - '@swagger-api/apidom-parser-adapter-asyncapi-yaml-2@1.0.0-beta.50': - resolution: {integrity: sha512-N48sPvIqBqlpgENjCun0/inWhFeRG+zK7JVU3lbVrDmSG1xh8cNk3OjKlSisuSwruWACL0z6cL5zdiBXc3FrNQ==} - - '@swagger-api/apidom-parser-adapter-json@1.0.0-beta.50': - resolution: {integrity: sha512-0gqtphuHO0tPnj4rV0x8VSyvDiCTkFY0vb5ILhVk9j5EW+31nxYVwDV8TBnEU5nJmnOvgvmdXhyl/2BvTjT87Q==} - - '@swagger-api/apidom-parser-adapter-openapi-json-2@1.0.0-beta.50': - resolution: {integrity: sha512-PF9sHWmfWeLIAX3QXKpEOHfSqzvOHK4uDMqF1BgEldqA20rdDH7p3Yedp8Q2EI9EhHvs6o2XqNtgQYHItTXBZg==} - - '@swagger-api/apidom-parser-adapter-openapi-json-3-0@1.0.0-beta.50': - resolution: {integrity: sha512-3/ORyJvETf4Xp8F7PyoKYa5655ndpZX4jxbodbh9JFN8Ruu6yj1parzmLpaL7VoTKbGlozc2TanpHlScBM4zrg==} - - '@swagger-api/apidom-parser-adapter-openapi-json-3-1@1.0.0-beta.50': - resolution: {integrity: sha512-W3sam19jtnj7A+HS1qbOa23ow/Nb1YwOXhAWUrqt4nGmwdOIfhqRcgKu3jMzGTQWn1dUq7EmmqIsxdI515dHVg==} - - '@swagger-api/apidom-parser-adapter-openapi-yaml-2@1.0.0-beta.50': - resolution: {integrity: sha512-9lWKmFpz/OdQG1PKIwQp+Xr70On45ycu1ioe0Awj9XPhcEGjvFoWysoYMQ9JfSaksj2esdhxpuNe/OUvwGLP9A==} - - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-0@1.0.0-beta.50': - resolution: {integrity: sha512-VsyYjWdv3Gy48OZKmeOkwGVIaeVBj/TT51mrVF75cH0OyJf9j0st5f2vfQbW98zkleX6vV6G8+ypPjV/gbzneQ==} - - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-1@1.0.0-beta.50': - resolution: {integrity: sha512-Un6K5upQveKG9BTMriRTzDkaE81bfnMOtiXdxrlReOwoVmmXDmpIIqoHRWxGj+Aa0Z700TGk+rlxdAEykdnRTw==} - - '@swagger-api/apidom-parser-adapter-yaml-1-2@1.0.0-beta.50': - resolution: {integrity: sha512-EPT4ArNGqRmsiS+dMQY5jTVvBZnBuVv7YvxG6vb3PTuGMG5rkrNIR4MiRQyNVrLdNvn8GxqqW+FJw6vZ/cB/ZA==} - - '@swagger-api/apidom-reference@1.0.0-beta.50': - resolution: {integrity: sha512-aD7gTWPgkJb9oYaC4jZPvxb7YbQKG9pWDYZigAkVGqOAbeYxUXeI00XyCLj/cH8l7KwyhTZNX70F7VnfxOkq7w==} - - '@swaggerexpert/cookie@2.0.2': - resolution: {integrity: sha512-DPI8YJ0Vznk4CT+ekn3rcFNq1uQwvUHZhH6WvTSPD0YKBIlMS9ur2RYKghXuxxOiqOam/i4lHJH4xTIiTgs3Mg==} - engines: {node: '>=12.20.0'} - - '@swaggerexpert/json-pointer@2.10.2': - resolution: {integrity: sha512-qMx1nOrzoB+PF+pzb26Q4Tc2sOlrx9Ba2UBNX9hB31Omrq+QoZ2Gly0KLrQWw4Of1AQ4J9lnD+XOdwOdcdXqqw==} - engines: {node: '>=12.20.0'} - - '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - - '@tailwindcss/forms@0.5.10': - resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} + '@tailwindcss/forms@0.5.11': + resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} peerDependencies: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' - '@tailwindcss/node@4.1.14': - resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} + '@tailwindcss/node@4.2.0': + resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} - '@tailwindcss/oxide-android-arm64@4.1.14': - resolution: {integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.0': + resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.14': - resolution: {integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.0': + resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.14': - resolution: {integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.0': + resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.14': - resolution: {integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.0': + resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': - resolution: {integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': - resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.1.14': - resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.1.14': - resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.1.14': - resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.1.14': - resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1665,328 +841,168 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': - resolution: {integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.14': - resolution: {integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.14': - resolution: {integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.0': + resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.1.14': - resolution: {integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==} + '@tailwindcss/vite@4.2.0': + resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/svelte@5.2.8': - resolution: {integrity: sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==} - engines: {node: '>= 10'} + '@testing-library/svelte-core@1.0.0': + resolution: {integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==} + engines: {node: '>=16'} peerDependencies: svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - vite: '*' - vitest: '*' - peerDependenciesMeta: - vite: - optional: true - vitest: - optional: true - - '@tree-sitter-grammars/tree-sitter-yaml@0.7.1': - resolution: {integrity: sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q==} - peerDependencies: - tree-sitter: ^0.22.4 - peerDependenciesMeta: - tree-sitter: - optional: true - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-axis@3.0.6': - resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} - - '@types/d3-brush@3.0.6': - resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} - - '@types/d3-chord@3.0.6': - resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-contour@3.0.6': - resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} - - '@types/d3-delaunay@6.0.4': - resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - - '@types/d3-dispatch@3.0.7': - resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} - - '@types/d3-drag@3.0.7': - resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - - '@types/d3-dsv@3.0.7': - resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-fetch@3.0.7': - resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} - - '@types/d3-force@3.0.10': - resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} - - '@types/d3-format@3.0.4': - resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - - '@types/d3-geo@3.1.0': - resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} - - '@types/d3-hierarchy@3.1.7': - resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-polygon@3.0.2': - resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} - - '@types/d3-quadtree@3.0.6': - resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} - - '@types/d3-scale-chromatic@3.1.0': - resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} - - '@types/d3-shape@3.1.7': - resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} - - '@types/d3-time-format@4.0.3': - resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/d3-transition@3.0.9': - resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} - - '@types/d3@7.4.3': - resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@0.0.39': - resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - - '@types/hast@2.3.10': - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash@4.17.20': - resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - - '@types/luxon@3.7.1': - resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} - - '@types/node@24.7.2': - resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==} - - '@types/pako@2.0.4': - resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} - - '@types/pdfkit@0.17.3': - resolution: {integrity: sha512-E4tp2qFaghqfS4K5TR4Gn1uTIkg0UAkhUgvVIszr5cS6ZmbioPWEkvhNDy3GtR9qdKC8DLQAnaaMlTcf346VsA==} - - '@types/phoenix@1.6.6': - resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} - '@types/raf@3.4.3': - resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - - '@types/ramda@0.30.2': - resolution: {integrity: sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA==} - - '@types/resolve@1.20.2': - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - - '@types/swagger-ui@5.21.1': - resolution: {integrity: sha512-DUmUH59eeOtvAqcWwBduH2ws0cc5i95KHsXCS4FsOfbUq/clW8TN+HqRBj7q5p9MSsSNK43RziIGItNbrAGLxg==} + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.46.0': - resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.46.0': - resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.0': - resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.0': - resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.0': - resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.0': - resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.0': - resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.0': - resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.0': - resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.0': - resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vercel/nft@0.30.2': - resolution: {integrity: sha512-pquXF3XZFg/T3TBor08rUhIGgOhdSilbn7WQLVP/aVSSO+25Rs4H/m3nxNDQ2x3znX7Z3yYjryN8xaLwypcwQg==} - engines: {node: '>=18'} + '@vercel/nft@1.3.1': + resolution: {integrity: sha512-ihNT1rswiq3cy4WKQAV5kJi6UjWX1vLUzlLc+Vvq83G8CU9nMgfDWz5f1tOnSlS8LeC4Wp4qTB3+HGj/ccUrFQ==} + engines: {node: '>=20'} hasBin: true - '@vite-pwa/sveltekit@1.0.0': - resolution: {integrity: sha512-Tv2Bb0EtXlkYQ/7sFSaK69KYcRJiybGvQbJOcdwna+GngCPAM1g1EsvKQ5Be+r7WqtNJfCzKnMhVVbOGxXjF5A==} - engines: {node: '>=18.13'} + '@vitest/browser-playwright@4.0.18': + resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} peerDependencies: - '@sveltejs/kit': ^1.3.1 || ^2.0.1 - '@vite-pwa/assets-generator': ^1.0.0 - peerDependenciesMeta: - '@vite-pwa/assets-generator': - optional: true + playwright: '*' + vitest: 4.0.18 + + '@vitest/browser@4.0.18': + resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + peerDependencies: + vitest: 4.0.18 - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - - '@yr/monotone-cubic-spline@1.0.3': - resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -2002,8 +1018,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -2014,269 +1030,75 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ansi-escapes@7.1.1: - resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==} - engines: {node: '>=18'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - apexcharts@4.7.0: - resolution: {integrity: sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==} - - apg-lite@1.0.5: - resolution: {integrity: sha512-SlI+nLMQDzCZfS39ihzjGp3JNBQfJXyMi6cg9tkLOCPVErgFsUIAEdO9IezR7kbP5Xd0ozcPNQBkf9TO5cHgWw==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - - autolinker@3.16.2: - resolution: {integrity: sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - babel-plugin-polyfill-corejs2@0.4.14: - resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-corejs3@0.13.0: - resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-regenerator@0.6.5: - resolution: {integrity: sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - - base64-js@0.0.8: - resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} - engines: {node: '>= 0.4'} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - baseline-browser-mapping@2.8.16: - resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==} - hasBin: true - - bin-links@5.0.0: - resolution: {integrity: sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==} - engines: {node: ^18.17.0 || >=20.5.0} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bits-ui@1.8.0: - resolution: {integrity: sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.11.0 - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - brotli@1.3.3: - resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - - browserslist@4.26.3: - resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - - bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} - engines: {node: '>=6.14.2'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001750: - resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==} - - canvas@3.2.0: - resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==} - engines: {node: ^18.12.0 || >= 20.9.0} - - canvg@3.0.11: - resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} - engines: {node: '>=10.0.0'} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - character-entities-legacy@1.1.4: - resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} - - character-entities@1.2.4: - resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} - - character-reference-invalid@1.1.4: - resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} - - chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} - - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - - cli-color@2.0.4: - resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} - engines: {node: '>=0.10'} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@5.1.0: - resolution: {integrity: sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==} - engines: {node: '>=20'} - - clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cmd-shim@7.0.0: - resolution: {integrity: sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==} - engines: {node: ^18.17.0 || >=20.5.0} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2284,238 +1106,38 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - comma-separated-tokens@1.0.8: - resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} - - commander@14.0.1: - resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} - engines: {node: '>=20'} - - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} - common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - - copy-to-clipboard@3.3.3: - resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - - core-js-compat@3.46.0: - resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} - - core-js-pure@3.46.0: - resolution: {integrity: sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==} - - core-js@3.46.0: - resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - - crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} - engines: {node: '>=8'} - - css-line-break@2.1.0: - resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-axis@3.0.0: - resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} - engines: {node: '>=12'} - - d3-brush@3.0.0: - resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} - engines: {node: '>=12'} - - d3-chord@3.0.1: - resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-contour@4.0.2: - resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} - engines: {node: '>=12'} - - d3-delaunay@6.0.4: - resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} - engines: {node: '>=12'} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-dsv@3.0.1: - resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} - engines: {node: '>=12'} - hasBin: true - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-fetch@3.0.1: - resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-format@3.1.0: - resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} - engines: {node: '>=12'} - - d3-geo@3.1.1: - resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} - engines: {node: '>=12'} - - d3-hierarchy@3.1.2: - resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-polygon@3.0.1: - resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-random@3.0.1: - resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} - engines: {node: '>=12'} - - d3-scale-chromatic@3.1.0: - resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: 2 - 3 - - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} - - d3@7.9.0: - resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} - engines: {node: '>=12'} - - d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2525,20 +1147,13 @@ packages: supports-color: optional: true - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} + dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2547,145 +1162,30 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - delaunator@5.0.1: - resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.3.2: - resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} - - dfa@1.2.0: - resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - dompurify@3.2.6: - resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} - - dompurify@3.2.7: - resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} - - drange@1.1.1: - resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} - engines: {node: '>=4'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - electron-to-chromium@1.5.234: - resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - - es6-weak-map@2.0.3: - resolution: {integrity: sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==} - - esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true - esbuild@0.25.10: - resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2696,11 +1196,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-svelte@3.12.4: - resolution: {integrity: sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==} + eslint-plugin-svelte@3.15.0: + resolution: {integrity: sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.1 || ^9.0.0 + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: @@ -2718,8 +1218,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.37.0: - resolution: {integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2731,20 +1235,21 @@ packages: esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} - esrap@2.1.0: - resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} + esrap@2.2.3: + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -2754,9 +1259,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@1.0.1: - resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -2767,51 +1269,19 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-png@6.4.0: - resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fault@1.0.4: - resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2821,13 +1291,6 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2835,13 +1298,6 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2853,48 +1309,6 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - fontkit@2.0.4: - resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - format@0.2.2: - resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} - engines: {node: '>=0.4.x'} - - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2905,159 +1319,44 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-own-enumerable-property-symbols@3.0.2: - resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - globalyzer@0.1.0: - resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} - - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + globals@17.3.0: + resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + engines: {node: '>=18'} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hast-util-parse-selector@2.2.5: - resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} - - hastscript@6.0.0: - resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==} - - highlight.js@10.7.3: - resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - - highlightjs-vue@1.0.0: - resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - html2canvas@1.4.1: - resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} - engines: {node: '>=8.0.0'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - husky@8.0.3: - resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} - engines: {node: '>=14'} - hasBin: true - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - idb@7.1.1: - resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -3067,10 +1366,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@3.8.2: - resolution: {integrity: sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==} - engines: {node: '>=0.10.0'} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3079,230 +1374,29 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - inline-style-parser@0.2.4: - resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - intl-messageformat@10.7.18: - resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} - - invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - - iobuffer@5.4.0: - resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} - - is-alphabetical@1.0.4: - resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} - - is-alphanumerical@1.0.4: - resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-decimal@1.0.4: - resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@1.0.4: - resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-obj@1.0.1: - resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} - engines: {node: '>=0.10.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - is-promise@2.2.2: - resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} - is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-regexp@1.0.0: - resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} - engines: {node: '>=0.10.0'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jpeg-exif@1.1.4: - resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} - - js-file-download@0.4.12: - resolution: {integrity: sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true + js-sha256@0.11.1: + resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -3311,12 +1405,6 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3325,15 +1413,9 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - - jspdf@3.0.3: - resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3345,100 +1427,88 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - - leaflet@2.0.0-alpha: - resolution: {integrity: sha512-RnEJ3UYcuHPFFsDq0e2/QovyIaR3hhwiQpq3wrKnvprA/lsiAJpcxvkRZL3UwQ2xnKm1r2pYcMa+rcSKVwEQag==} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-darwin-arm64@1.30.1: - resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.1: - resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.1: - resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.1: - resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.1: - resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.30.1: - resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.30.1: - resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.30.1: - resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.30.1: - resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.1: - resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.1: - resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} - linebreak@1.1.0: - resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} - - lint-staged@16.2.4: - resolution: {integrity: sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg==} - engines: {node: '>=20.17'} - hasBin: true - - listr2@9.0.4: - resolution: {integrity: sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==} - engines: {node: '>=20.0.0'} - locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -3446,128 +1516,39 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - lowlight@1.20.0: - resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lru-queue@0.1.0: - resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} - - luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} - engines: {node: '>=12'} - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.25.9: - resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} - - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - memoizee@0.4.17: - resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==} - engines: {node: '>=0.12'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} mini-svg-data-uri@1.4.4: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true - minim@0.23.8: - resolution: {integrity: sha512-bjdr2xW1dBCMsMGGsUeqM4eFI60m94+szhxWys+B1ztIt6gWSfeGBdSVCIawezeHYLYn0j6zrsXdQS/JllBzww==} - engines: {node: '>=6'} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@7.4.6: - resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} - engines: {node: '>=10'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3579,51 +1560,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nano-spawn@2.0.0: - resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} - engines: {node: '>=20.17'} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} - engines: {node: '>= 10'} - - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - - node-abi@3.78.0: - resolution: {integrity: sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==} - engines: {node: '>=10'} - - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} - engines: {node: ^18 || ^20 || >= 21} - - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-fetch-commonjs@3.3.2: - resolution: {integrity: sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3633,68 +1577,22 @@ packages: encoding: optional: true - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-releases@2.0.23: - resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} - nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} hasBin: true - npm-normalize-package-bin@4.0.0: - resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} - engines: {node: ^18.17.0 || >=20.5.0} - - nwsapi@2.2.22: - resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - openapi-path-templating@2.2.1: - resolution: {integrity: sha512-eN14VrDvl/YyGxxrkGOHkVkWEoPyhyeydOUrbvjoz8K5eIGgELASwN1eqFOJ2CTQMGCy2EntOK1KdtJ8ZMekcg==} - engines: {node: '>=12.20.0'} - - openapi-server-url-templating@1.3.0: - resolution: {integrity: sha512-DPlCms3KKEbjVQb0spV6Awfn6UWNheuG/+folQPzh/wUaKwuqvj8zt5gagD7qoyxtE03cIiKPgLFS3Q8Bz00uQ==} - engines: {node: '>=12.20.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3703,89 +1601,49 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - pako@0.2.9: - resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} - - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-entities@2.0.0: - resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - pdfkit@0.17.2: - resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==} - - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} hasBin: true - playwright-core@1.56.0: - resolution: {integrity: sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.56.0: - resolution: {integrity: sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true - png-js@1.0.0: - resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} @@ -3815,32 +1673,27 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - hasBin: true - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-svelte@3.4.0: - resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} + prettier-plugin-svelte@3.5.0: + resolution: {integrity: sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier-plugin-tailwindcss@0.6.14: - resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} - engines: {node: '>=14.21.3'} + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' '@prettier/plugin-hermes': '*' @@ -3852,14 +1705,12 @@ packages: prettier: ^3.0 prettier-plugin-astro: '*' prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' prettier-plugin-jsdoc: '*' prettier-plugin-marko: '*' prettier-plugin-multiline-arrays: '*' prettier-plugin-organize-attributes: '*' prettier-plugin-organize-imports: '*' prettier-plugin-sort-imports: '*' - prettier-plugin-style-order: '*' prettier-plugin-svelte: '*' peerDependenciesMeta: '@ianvs/prettier-plugin-sort-imports': @@ -3880,8 +1731,6 @@ packages: optional: true prettier-plugin-css-order: optional: true - prettier-plugin-import-sort: - optional: true prettier-plugin-jsdoc: optional: true prettier-plugin-marko: @@ -3894,6304 +1743,1980 @@ packages: optional: true prettier-plugin-sort-imports: optional: true - prettier-plugin-style-order: - optional: true prettier-plugin-svelte: optional: true - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true - pretty-bytes@5.6.0: - resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pretty-bytes@6.1.1: - resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} - engines: {node: ^14.13.1 || >=16.0.0} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} - prismjs@1.27.0: - resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} - engines: {node: '>=6'} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} - property-information@5.6.0: - resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - raf@3.4.1: - resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} - - ramda-adjunct@5.1.0: - resolution: {integrity: sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==} - engines: {node: '>=0.10.3'} - peerDependencies: - ramda: '>= 0.30.0' - - ramda@0.30.1: - resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==} - - randexp@0.5.3: - resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==} - engines: {node: '>=4'} - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} - react-copy-to-clipboard@5.1.0: - resolution: {integrity: sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==} + sqlite-wasm-kysely@0.3.0: + resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} peerDependencies: - react: ^15.3.0 || 16 || 17 || 18 + kysely: '*' - react-debounce-input@3.3.0: - resolution: {integrity: sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==} - peerDependencies: - react: ^15.3.0 || 16 || 17 || 18 + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - react-dom@19.2.0: - resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} - peerDependencies: - react: ^19.2.0 + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - react-immutable-proptypes@2.2.0: - resolution: {integrity: sha512-Vf4gBsePlwdGvSZoLSBfd4HAP93HDauMY4fDjXhreg/vg6F3Fj/MXDNyTbltPC/xZKmZc+cjLu3598DdYK6sgQ==} - peerDependencies: - immutable: '>=3.6.2' + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} - react-immutable-pure-component@2.2.2: - resolution: {integrity: sha512-vkgoMJUDqHZfXXnjVlG3keCxSO/U6WeDQ5/Sl0GK2cH8TOxEzQ5jXqDXHEL/jqk6fsNxV05oH5kD7VNMUE2k+A==} - peerDependencies: - immutable: '>= 2 || >= 4.0.0-rc' - react: '>= 16.6' - react-dom: '>= 16.6' + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} - react-inspector@6.0.2: - resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==} + svelte-check@4.4.1: + resolution: {integrity: sha512-y1bBT0CRCMMfdjyqX1e5zCygLgEEr4KJV1qP6GSUReHl90bmcQaAWjZygHPfQ8K63f1eR8IuivuZMwmCg3zT2Q==} + engines: {node: '>= 18.0.0'} + hasBin: true peerDependencies: - react: ^16.8.4 || ^17.0.0 || ^18.0.0 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' - react-redux@9.2.0: - resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + svelte-eslint-parser@1.4.1: + resolution: {integrity: sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.24.0} peerDependencies: - '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: - '@types/react': - optional: true - redux: + svelte: optional: true - react-syntax-highlighter@15.6.6: - resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==} - peerDependencies: - react: '>= 0.14.0' - - react@19.2.0: - resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} - engines: {node: '>=0.10.0'} - - read-cmd-shim@5.0.0: - resolution: {integrity: sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==} - engines: {node: ^18.17.0 || >=20.5.0} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + svelte@5.53.0: + resolution: {integrity: sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==} + engines: {node: '>=18'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + tailwindcss@4.2.0: + resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} - redux-immutable@4.0.0: - resolution: {integrity: sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==} - peerDependencies: - immutable: ^3.8.1 || ^4.0.0-rc.1 + tar@7.5.9: + resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} + engines: {node: '>=18'} - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} - refractor@3.6.0: - resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} - regenerate-unicode-properties@10.2.2: - resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} - engines: {node: '>=4'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} - regenerate@1.4.2: - resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} - regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' - regexpu-core@6.4.0: - resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} - engines: {node: '>=4'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - regjsgen@0.8.0: - resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} - regjsparser@0.13.0: - resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} - hasBin: true + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' - remarkable@2.0.1: - resolution: {integrity: sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==} - engines: {node: '>= 6.0.0'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} hasBin: true - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - restructure@3.0.2: - resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} - - ret@0.2.2: - resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} - engines: {node: '>=4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rgbcolor@1.0.1: - resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} - engines: {node: '>= 0.8.15'} - - robust-predicates@3.0.2: - resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - - rollup@2.79.2: - resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==} - engines: {node: '>=10.0.0'} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - rollup@4.52.4: - resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - runed@0.23.4: - resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==} + vite-plugin-devtools-json@1.0.0: + resolution: {integrity: sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw==} peerDependencies: - svelte: ^5.7.0 - - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + 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 - serialize-error@8.1.0: - resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} - engines: {node: '>=10'} + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + vitest-browser-svelte@2.0.2: + resolution: {integrity: sha512-OLJVYoIYflwToFIy3s41pZ9mVp6dwXfYd8IIsWoc57g8DyN3SxsNJ5GB1xWFPxLFlKM+1MPExjPxLaqdELrfRQ==} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vitest: ^4.0.0 - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} hasBin: true - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} - - short-unique-id@5.3.2: - resolution: {integrity: sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg==} hasBin: true - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - smob@1.5.0: - resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions - - sourcemap-codec@1.4.8: - resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} - deprecated: Please use @jridgewell/sourcemap-codec instead - - space-separated-tokens@1.1.5: - resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} - stackblur-canvas@2.7.0: - resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} - engines: {node: '>=0.1.14'} - - std-env@3.9.0: - resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string-width@8.1.0: - resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} - engines: {node: '>=20'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - stringify-object@3.3.0: - resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} - engines: {node: '>=4'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - strip-comments@2.0.1: - resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} - engines: {node: '>=10'} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - - stripe@18.5.0: - resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} - engines: {node: '>=12.*'} - peerDependencies: - '@types/node': '>=12.x.x' - peerDependenciesMeta: - '@types/node': - optional: true - - style-to-object@1.0.11: - resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} - - supabase@2.51.0: - resolution: {integrity: sha512-/veWvAlztU+T+l012O1R3g6poEhP/nqAGbdU5ws0nICTD8cY9bMgn68YJFVDKcQJVhO7NCJ1LbX/IgyYKNNvBw==} - engines: {npm: '>=8'} - hasBin: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - svelte-check@4.3.3: - resolution: {integrity: sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==} - engines: {node: '>= 18.0.0'} - hasBin: true - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' - - svelte-eslint-parser@1.3.3: - resolution: {integrity: sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - svelte: - optional: true - - svelte-i18n@4.0.1: - resolution: {integrity: sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==} - engines: {node: '>= 16'} - hasBin: true - peerDependencies: - svelte: ^3 || ^4 || ^5 - - svelte-toolbelt@0.7.1: - resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==} - engines: {node: '>=18', pnpm: '>=8.7.0'} - peerDependencies: - svelte: ^5.0.0 - - svelte@5.39.11: - resolution: {integrity: sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw==} - engines: {node: '>=18'} - - svg-pathdata@6.0.3: - resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} - engines: {node: '>=12.0.0'} - - swagger-client@3.35.7: - resolution: {integrity: sha512-AAVk7lBFIw41wI0tsqyh/l4dwJ0/eslHL2Ex4hmsGtuKcD6/wXunetO8AsmE5MptK4YgRvpmUDvKnF1TaGzdiQ==} - - swagger-ui@5.29.4: - resolution: {integrity: sha512-IKdluQZUaQ/ldsFFdh9bOg9QPtYFcrnp8hBoh3zIKqj9hyr3OMbe9OgZMTEoKDwrTr+FHydESm7EzX8H8Do9mQ==} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tabbable@6.2.0: - resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} - - tailwindcss@4.1.14: - resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar@7.5.1: - resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} - engines: {node: '>=18'} - - temp-dir@2.0.0: - resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} - engines: {node: '>=8'} - - tempy@0.6.0: - resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} - engines: {node: '>=10'} - - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} - engines: {node: '>=10'} - hasBin: true - - text-segmentation@1.0.3: - resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} - - timers-ext@0.1.8: - resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} - engines: {node: '>=0.12'} - - tiny-glob@0.2.9: - resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} - - tiny-inflate@1.0.3: - resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} - engines: {node: '>=14.0.0'} - - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} - hasBin: true - - to-buffer@1.2.2: - resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} - engines: {node: '>= 0.4'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toggle-selection@1.0.6: - resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} - - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} - engines: {node: '>=16'} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - - tree-sitter-json@0.24.8: - resolution: {integrity: sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ==} - peerDependencies: - tree-sitter: ^0.21.1 - peerDependenciesMeta: - tree-sitter: - optional: true - - tree-sitter@0.21.1: - resolution: {integrity: sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==} - - tree-sitter@0.22.4: - resolution: {integrity: sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==} - - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - ts-mixer@6.0.4: - resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - - ts-toolbelt@9.6.0: - resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@0.16.0: - resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} - engines: {node: '>=10'} - - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - types-ramda@0.30.1: - resolution: {integrity: sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA==} - - typescript-eslint@8.46.0: - resolution: {integrity: sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@7.14.0: - resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} - - unicode-canonical-property-names-ecmascript@2.0.1: - resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} - engines: {node: '>=4'} - - unicode-match-property-ecmascript@2.0.0: - resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} - engines: {node: '>=4'} - - unicode-match-property-value-ecmascript@2.2.1: - resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} - engines: {node: '>=4'} - - unicode-properties@1.4.1: - resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} - - unicode-property-aliases-ecmascript@2.2.0: - resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} - engines: {node: '>=4'} - - unicode-trie@2.0.0: - resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} - - unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} - engines: {node: '>=8'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unraw@3.0.0: - resolution: {integrity: sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==} - - upath@1.2.0: - resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} - engines: {node: '>=4'} - - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - utf-8-validate@6.0.5: - resolution: {integrity: sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA==} - engines: {node: '>=6.14.2'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - utrie@1.0.2: - resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} - - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite-plugin-commonjs@0.10.4: - resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==} - - vite-plugin-dynamic-import@1.6.0: - resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==} - - vite-plugin-pwa@1.0.3: - resolution: {integrity: sha512-/OpqIpUldALGxcsEnv/ekQiQ5xHkQ53wcoN5ewX4jiIDNGs3W+eNcI1WYZeyOLmzoEjg09D7aX0O89YGjen1aw==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@vite-pwa/assets-generator': ^1.0.0 - vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - workbox-build: ^7.3.0 - workbox-window: ^7.3.0 - peerDependenciesMeta: - '@vite-pwa/assets-generator': - optional: true - - vite@6.3.6: - resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - 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 - - vitefu@1.1.1: - resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - peerDependenciesMeta: - vite: - optional: true - - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/debug': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - - web-tree-sitter@0.24.5: - resolution: {integrity: sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - workbox-background-sync@7.3.0: - resolution: {integrity: sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==} - - workbox-broadcast-update@7.3.0: - resolution: {integrity: sha512-T9/F5VEdJVhwmrIAE+E/kq5at2OY6+OXXgOWQevnubal6sO92Gjo24v6dCVwQiclAF5NS3hlmsifRrpQzZCdUA==} - - workbox-build@7.3.0: - resolution: {integrity: sha512-JGL6vZTPlxnlqZRhR/K/msqg3wKP+m0wfEUVosK7gsYzSgeIxvZLi1ViJJzVL7CEeI8r7rGFV973RiEqkP3lWQ==} - engines: {node: '>=16.0.0'} - - workbox-cacheable-response@7.3.0: - resolution: {integrity: sha512-eAFERIg6J2LuyELhLlmeRcJFa5e16Mj8kL2yCDbhWE+HUun9skRQrGIFVUagqWj4DMaaPSMWfAolM7XZZxNmxA==} - - workbox-core@7.3.0: - resolution: {integrity: sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==} - - workbox-expiration@7.3.0: - resolution: {integrity: sha512-lpnSSLp2BM+K6bgFCWc5bS1LR5pAwDWbcKt1iL87/eTSJRdLdAwGQznZE+1czLgn/X05YChsrEegTNxjM067vQ==} - - workbox-google-analytics@7.3.0: - resolution: {integrity: sha512-ii/tSfFdhjLHZ2BrYgFNTrb/yk04pw2hasgbM70jpZfLk0vdJAXgaiMAWsoE+wfJDNWoZmBYY0hMVI0v5wWDbg==} - - workbox-navigation-preload@7.3.0: - resolution: {integrity: sha512-fTJzogmFaTv4bShZ6aA7Bfj4Cewaq5rp30qcxl2iYM45YD79rKIhvzNHiFj1P+u5ZZldroqhASXwwoyusnr2cg==} - - workbox-precaching@7.3.0: - resolution: {integrity: sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==} - - workbox-range-requests@7.3.0: - resolution: {integrity: sha512-EyFmM1KpDzzAouNF3+EWa15yDEenwxoeXu9bgxOEYnFfCxns7eAxA9WSSaVd8kujFFt3eIbShNqa4hLQNFvmVQ==} - - workbox-recipes@7.3.0: - resolution: {integrity: sha512-BJro/MpuW35I/zjZQBcoxsctgeB+kyb2JAP5EB3EYzePg8wDGoQuUdyYQS+CheTb+GhqJeWmVs3QxLI8EBP1sg==} - - workbox-routing@7.3.0: - resolution: {integrity: sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A==} - - workbox-strategies@7.3.0: - resolution: {integrity: sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg==} - - workbox-streams@7.3.0: - resolution: {integrity: sha512-SZnXucyg8x2Y61VGtDjKPO5EgPUG5NDn/v86WYHX+9ZqvAsGOytP0Jxp1bl663YUuMoXSAtsGLL+byHzEuMRpw==} - - workbox-sw@7.3.0: - resolution: {integrity: sha512-aCUyoAZU9IZtH05mn0ACUpyHzPs0lMeJimAYkQkBsOWiqaJLgusfDCR+yllkPkFRxWpZKF8vSvgHYeG7LwhlmA==} - - workbox-window@7.3.0: - resolution: {integrity: sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@6.0.0: - resolution: {integrity: sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-but-prettier@1.0.1: - resolution: {integrity: sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==} - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xml@1.0.1: - resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} - engines: {node: '>= 14.6'} - hasBin: true - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zenscroll@4.0.2: - resolution: {integrity: sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - -snapshots: - - '@adobe/css-tools@4.4.4': {} - - '@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)': - dependencies: - ajv: 8.17.1 - json-schema: 0.4.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.28.4': {} - - '@babel/core@7.28.4': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.28.3': - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.28.4 - - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.26.3 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.27.1 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.4 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-create-regexp-features-plugin@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - regexpu-core: 6.4.0 - semver: 6.3.1 - - '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.10 - transitivePeerDependencies: - - supports-color - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-member-expression-to-functions@7.27.1': - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.28.4 - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-member-expression-to-functions': 7.27.1 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.27.1': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helper-wrap-function@7.28.3': - dependencies: - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - - '@babel/parser@7.28.4': - dependencies: - '@babel/types': 7.28.4 - - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - - '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-block-scoping@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-classes@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-globals': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/template': 7.27.2 - - '@babel/plugin-transform-destructuring@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-exponentiation-operator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-logical-assignment-operators@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-modules-systemjs@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-spread@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/preset-env@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.3(@babel/core@7.28.4) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.4) - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.28.4) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoping': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.4) - '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-explicit-resource-management': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-exponentiation-operator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-logical-assignment-operators': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-systemjs': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.28.4) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.4) - babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.28.4) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.4) - babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.28.4) - core-js-compat: 3.46.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.4 - esutils: 2.0.3 - - '@babel/runtime-corejs3@7.28.4': - dependencies: - core-js-pure: 3.46.0 - - '@babel/runtime@7.28.4': {} - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - - '@babel/traverse@7.28.4': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.4': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - - '@csstools/color-helpers@5.1.0': {} - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-tokenizer@3.0.4': {} - - '@esbuild/aix-ppc64@0.19.12': - optional: true - - '@esbuild/aix-ppc64@0.25.10': - optional: true - - '@esbuild/android-arm64@0.19.12': - optional: true - - '@esbuild/android-arm64@0.25.10': - optional: true - - '@esbuild/android-arm@0.19.12': - optional: true - - '@esbuild/android-arm@0.25.10': - optional: true - - '@esbuild/android-x64@0.19.12': - optional: true - - '@esbuild/android-x64@0.25.10': - optional: true - - '@esbuild/darwin-arm64@0.19.12': - optional: true - - '@esbuild/darwin-arm64@0.25.10': - optional: true - - '@esbuild/darwin-x64@0.19.12': - optional: true - - '@esbuild/darwin-x64@0.25.10': - optional: true - - '@esbuild/freebsd-arm64@0.19.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.10': - optional: true - - '@esbuild/freebsd-x64@0.19.12': - optional: true - - '@esbuild/freebsd-x64@0.25.10': - optional: true - - '@esbuild/linux-arm64@0.19.12': - optional: true - - '@esbuild/linux-arm64@0.25.10': - optional: true - - '@esbuild/linux-arm@0.19.12': - optional: true - - '@esbuild/linux-arm@0.25.10': - optional: true - - '@esbuild/linux-ia32@0.19.12': - optional: true - - '@esbuild/linux-ia32@0.25.10': - optional: true - - '@esbuild/linux-loong64@0.19.12': - optional: true - - '@esbuild/linux-loong64@0.25.10': - optional: true - - '@esbuild/linux-mips64el@0.19.12': - optional: true - - '@esbuild/linux-mips64el@0.25.10': - optional: true - - '@esbuild/linux-ppc64@0.19.12': - optional: true - - '@esbuild/linux-ppc64@0.25.10': - optional: true - - '@esbuild/linux-riscv64@0.19.12': - optional: true - - '@esbuild/linux-riscv64@0.25.10': - optional: true - - '@esbuild/linux-s390x@0.19.12': - optional: true - - '@esbuild/linux-s390x@0.25.10': - optional: true - - '@esbuild/linux-x64@0.19.12': - optional: true - - '@esbuild/linux-x64@0.25.10': - optional: true - - '@esbuild/netbsd-arm64@0.25.10': - optional: true - - '@esbuild/netbsd-x64@0.19.12': - optional: true - - '@esbuild/netbsd-x64@0.25.10': - optional: true - - '@esbuild/openbsd-arm64@0.25.10': - optional: true - - '@esbuild/openbsd-x64@0.19.12': - optional: true - - '@esbuild/openbsd-x64@0.25.10': - optional: true - - '@esbuild/openharmony-arm64@0.25.10': - optional: true - - '@esbuild/sunos-x64@0.19.12': - optional: true - - '@esbuild/sunos-x64@0.25.10': - optional: true - - '@esbuild/win32-arm64@0.19.12': - optional: true - - '@esbuild/win32-arm64@0.25.10': - optional: true - - '@esbuild/win32-ia32@0.19.12': - optional: true - - '@esbuild/win32-ia32@0.25.10': - optional: true - - '@esbuild/win32-x64@0.19.12': - optional: true - - '@esbuild/win32-x64@0.25.10': - optional: true - - '@eslint-community/eslint-utils@4.9.0(eslint@9.37.0(jiti@2.6.1))': - dependencies: - eslint: 9.37.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.1': {} - - '@eslint/compat@1.4.0(eslint@9.37.0(jiti@2.6.1))': - dependencies: - '@eslint/core': 0.16.0 - optionalDependencies: - eslint: 9.37.0(jiti@2.6.1) - - '@eslint/config-array@0.21.0': - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.0': - dependencies: - '@eslint/core': 0.16.0 - - '@eslint/core@0.16.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.1': - dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.37.0': {} - - '@eslint/object-schema@2.1.6': {} - - '@eslint/plugin-kit@0.4.0': - dependencies: - '@eslint/core': 0.16.0 - levn: 0.4.1 - - '@floating-ui/core@1.7.3': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.4': - dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/utils@0.2.10': {} - - '@formatjs/ecma402-abstract@2.3.6': - dependencies: - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/intl-localematcher': 0.6.2 - decimal.js: 10.6.0 - tslib: 2.8.1 - - '@formatjs/fast-memoize@2.2.7': - dependencies: - tslib: 2.8.1 - - '@formatjs/icu-messageformat-parser@2.11.4': - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/icu-skeleton-parser': 1.8.16 - tslib: 2.8.1 - - '@formatjs/icu-skeleton-parser@1.8.16': - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - tslib: 2.8.1 - - '@formatjs/intl-localematcher@0.6.2': - dependencies: - tslib: 2.8.1 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@internationalized/date@3.10.0': - dependencies: - '@swc/helpers': 0.5.17 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@kurkle/color@0.3.4': {} - - '@mapbox/node-pre-gyp@2.0.0': - dependencies: - consola: 3.4.2 - detect-libc: 2.1.2 - https-proxy-agent: 7.0.6 - node-fetch: 2.7.0 - nopt: 8.1.0 - semver: 7.7.3 - tar: 7.5.1 - transitivePeerDependencies: - - encoding - - supports-color - - '@mdi/js@7.4.47': {} - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@playwright/test@1.56.0': - dependencies: - playwright: 1.56.0 - - '@polka/url@1.0.0-next.29': {} - - '@rollup/plugin-babel@5.3.1(@babel/core@7.28.4)(rollup@2.79.2)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@rollup/pluginutils': 3.1.0(rollup@2.79.2) - rollup: 2.79.2 - transitivePeerDependencies: - - supports-color - - '@rollup/plugin-node-resolve@15.3.1(rollup@2.79.2)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.10 - optionalDependencies: - rollup: 2.79.2 - - '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': - dependencies: - '@rollup/pluginutils': 3.1.0(rollup@2.79.2) - magic-string: 0.25.9 - rollup: 2.79.2 - - '@rollup/plugin-terser@0.4.4(rollup@2.79.2)': - dependencies: - serialize-javascript: 6.0.2 - smob: 1.5.0 - terser: 5.44.0 - optionalDependencies: - rollup: 2.79.2 - - '@rollup/pluginutils@3.1.0(rollup@2.79.2)': - dependencies: - '@types/estree': 0.0.39 - estree-walker: 1.0.1 - picomatch: 2.3.1 - rollup: 2.79.2 - - '@rollup/pluginutils@5.3.0(rollup@2.79.2)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 2.79.2 - - '@rollup/rollup-android-arm-eabi@4.52.4': - optional: true - - '@rollup/rollup-android-arm64@4.52.4': - optional: true - - '@rollup/rollup-darwin-arm64@4.34.9': - optional: true - - '@rollup/rollup-darwin-arm64@4.52.4': - optional: true - - '@rollup/rollup-darwin-x64@4.34.9': - optional: true - - '@rollup/rollup-darwin-x64@4.52.4': - optional: true - - '@rollup/rollup-freebsd-arm64@4.52.4': - optional: true - - '@rollup/rollup-freebsd-x64@4.52.4': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.52.4': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.52.4': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.34.9': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.34.9': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.52.4': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.52.4': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.34.9': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.52.4': - optional: true - - '@rollup/rollup-linux-x64-musl@4.34.9': - optional: true - - '@rollup/rollup-linux-x64-musl@4.52.4': - optional: true - - '@rollup/rollup-openharmony-arm64@4.52.4': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.34.9': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.52.4': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.52.4': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.52.4': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.34.9': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.52.4': - optional: true - - '@scarf/scarf@1.4.0': {} - - '@standard-schema/spec@1.0.0': {} - - '@stencil/core@4.35.1': - optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.34.9 - '@rollup/rollup-darwin-x64': 4.34.9 - '@rollup/rollup-linux-arm64-gnu': 4.34.9 - '@rollup/rollup-linux-arm64-musl': 4.34.9 - '@rollup/rollup-linux-x64-gnu': 4.34.9 - '@rollup/rollup-linux-x64-musl': 4.34.9 - '@rollup/rollup-win32-arm64-msvc': 4.34.9 - '@rollup/rollup-win32-x64-msvc': 4.34.9 - - '@stencil/store@2.2.0(@stencil/core@4.35.1)': - dependencies: - '@stencil/core': 4.35.1 - - '@stripe/stripe-js@7.9.0': {} - - '@supabase/auth-js@2.75.0': - dependencies: - '@supabase/node-fetch': 2.6.15 - - '@supabase/functions-js@2.75.0': - dependencies: - '@supabase/node-fetch': 2.6.15 - - '@supabase/node-fetch@2.6.15': - dependencies: - whatwg-url: 5.0.0 - - '@supabase/postgrest-js@2.75.0': - dependencies: - '@supabase/node-fetch': 2.6.15 - - '@supabase/realtime-js@2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': - dependencies: - '@supabase/node-fetch': 2.6.15 - '@types/phoenix': 1.6.6 - '@types/ws': 8.18.1 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@supabase/ssr@0.6.1(@supabase/supabase-js@2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))': - dependencies: - '@supabase/supabase-js': 2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) - cookie: 1.0.2 - - '@supabase/storage-js@2.75.0': - dependencies: - '@supabase/node-fetch': 2.6.15 - - '@supabase/supabase-js@2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': - dependencies: - '@supabase/auth-js': 2.75.0 - '@supabase/functions-js': 2.75.0 - '@supabase/node-fetch': 2.6.15 - '@supabase/postgrest-js': 2.75.0 - '@supabase/realtime-js': 2.75.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) - '@supabase/storage-js': 2.75.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@surma/rollup-plugin-off-main-thread@2.2.3': - dependencies: - ejs: 3.1.10 - json5: 2.2.3 - magic-string: 0.25.9 - string.prototype.matchall: 4.0.12 - - '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': - dependencies: - acorn: 8.15.0 - - '@sveltejs/adapter-vercel@5.10.3(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(rollup@2.79.2)': - dependencies: - '@sveltejs/kit': 2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - '@vercel/nft': 0.30.2(rollup@2.79.2) - esbuild: 0.25.10 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@standard-schema/spec': 1.0.0 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - '@types/cookie': 0.6.0 - acorn: 8.15.0 - cookie: 0.6.0 - devalue: 5.3.2 - esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.19 - mrmime: 2.0.1 - sade: 1.8.1 - set-cookie-parser: 2.7.1 - sirv: 3.0.2 - svelte: 5.39.11 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - debug: 4.4.3 - svelte: 5.39.11 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - - '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - debug: 4.4.3 - deepmerge: 4.3.1 - kleur: 4.1.5 - magic-string: 0.30.19 - svelte: 5.39.11 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - transitivePeerDependencies: - - supports-color - - '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)': - dependencies: - '@svgdotjs/svg.js': 3.2.5 - - '@svgdotjs/svg.filter.js@3.0.9': - dependencies: - '@svgdotjs/svg.js': 3.2.5 - - '@svgdotjs/svg.js@3.2.5': {} - - '@svgdotjs/svg.resize.js@2.0.5(@svgdotjs/svg.js@3.2.5)(@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.5))': - dependencies: - '@svgdotjs/svg.js': 3.2.5 - '@svgdotjs/svg.select.js': 4.0.3(@svgdotjs/svg.js@3.2.5) - - '@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.5)': - dependencies: - '@svgdotjs/svg.js': 3.2.5 - - '@swagger-api/apidom-ast@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - unraw: 3.0.0 - - '@swagger-api/apidom-core@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-ast': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - minim: 0.23.8 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - short-unique-id: 5.3.2 - ts-mixer: 6.0.4 - - '@swagger-api/apidom-error@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - - '@swagger-api/apidom-json-pointer@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swaggerexpert/json-pointer': 2.10.2 - - '@swagger-api/apidom-ns-api-design-systems@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-1': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - optional: true - - '@swagger-api/apidom-ns-arazzo-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-2020-12': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - optional: true - - '@swagger-api/apidom-ns-asyncapi-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-7': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - optional: true - - '@swagger-api/apidom-ns-json-schema-2019-09@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-7': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-json-schema-2020-12@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-2019-09': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-json-schema-draft-4@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-ast': 1.0.0-beta.50 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-json-schema-draft-6@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-4': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-json-schema-draft-7@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-6': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-openapi-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-4': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - optional: true - - '@swagger-api/apidom-ns-openapi-3-0@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-draft-4': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-ns-openapi-3-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-ast': 1.0.0-beta.50 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-json-pointer': 1.0.0-beta.50 - '@swagger-api/apidom-ns-json-schema-2020-12': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-0': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - ts-mixer: 6.0.4 - - '@swagger-api/apidom-parser-adapter-api-design-systems-json@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-api-design-systems': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-api-design-systems-yaml@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-api-design-systems': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-arazzo-json-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-arazzo-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-arazzo-yaml-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-arazzo-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-asyncapi-json-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-asyncapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-asyncapi-yaml-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-asyncapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-json@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-ast': 1.0.0-beta.50 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - tree-sitter: 0.21.1 - tree-sitter-json: 0.24.8(tree-sitter@0.21.1) - web-tree-sitter: 0.24.5 - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-json-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-json-3-0@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-0': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-json-3-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-yaml-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-0@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-0': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-1@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optional: true - - '@swagger-api/apidom-parser-adapter-yaml-1-2@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-ast': 1.0.0-beta.50 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@tree-sitter-grammars/tree-sitter-yaml': 0.7.1(tree-sitter@0.22.4) - '@types/ramda': 0.30.2 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - tree-sitter: 0.22.4 - web-tree-sitter: 0.24.5 - optional: true - - '@swagger-api/apidom-reference@1.0.0-beta.50': - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@types/ramda': 0.30.2 - axios: 1.12.2 - minimatch: 7.4.6 - process: 0.11.10 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - optionalDependencies: - '@swagger-api/apidom-json-pointer': 1.0.0-beta.50 - '@swagger-api/apidom-ns-arazzo-1': 1.0.0-beta.50 - '@swagger-api/apidom-ns-asyncapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-2': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-0': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-api-design-systems-json': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-api-design-systems-yaml': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-arazzo-json-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-arazzo-yaml-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-asyncapi-json-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-asyncapi-yaml-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-json': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-json-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-json-3-0': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-json-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-yaml-2': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-0': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-openapi-yaml-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-parser-adapter-yaml-1-2': 1.0.0-beta.50 - transitivePeerDependencies: - - debug - - '@swaggerexpert/cookie@2.0.2': - dependencies: - apg-lite: 1.0.5 - - '@swaggerexpert/json-pointer@2.10.2': - dependencies: - apg-lite: 1.0.5 - - '@swc/helpers@0.5.17': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)': - dependencies: - mini-svg-data-uri: 1.4.4 - tailwindcss: 4.1.14 - - '@tailwindcss/node@4.1.14': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 - jiti: 2.6.1 - lightningcss: 1.30.1 - magic-string: 0.30.19 - source-map-js: 1.2.1 - tailwindcss: 4.1.14 - - '@tailwindcss/oxide-android-arm64@4.1.14': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.14': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.14': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.14': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.14': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.14': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.14': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.14': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.14': - optional: true - - '@tailwindcss/oxide@4.1.14': - dependencies: - detect-libc: 2.1.2 - tar: 7.5.1 - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.14 - '@tailwindcss/oxide-darwin-arm64': 4.1.14 - '@tailwindcss/oxide-darwin-x64': 4.1.14 - '@tailwindcss/oxide-freebsd-x64': 4.1.14 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.14 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.14 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.14 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.14 - '@tailwindcss/oxide-linux-x64-musl': 4.1.14 - '@tailwindcss/oxide-wasm32-wasi': 4.1.14 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.14 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.14 - - '@tailwindcss/typography@0.5.19(tailwindcss@4.1.14)': - dependencies: - postcss-selector-parser: 6.0.10 - tailwindcss: 4.1.14 - - '@tailwindcss/vite@4.1.14(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@tailwindcss/node': 4.1.14 - '@tailwindcss/oxide': 4.1.14 - tailwindcss: 4.1.14 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.4 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.4.4 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/svelte@5.2.8(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/node@24.7.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@testing-library/dom': 10.4.1 - svelte: 5.39.11 - optionalDependencies: - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - vitest: 3.2.4(@types/node@24.7.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - - '@tree-sitter-grammars/tree-sitter-yaml@0.7.1(tree-sitter@0.22.4)': - dependencies: - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - optionalDependencies: - tree-sitter: 0.22.4 - optional: true - - '@types/aria-query@5.0.4': {} - - '@types/chai@5.2.2': - dependencies: - '@types/deep-eql': 4.0.2 - - '@types/cookie@0.6.0': {} - - '@types/d3-array@3.2.2': {} - - '@types/d3-axis@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-brush@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-chord@3.0.6': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-contour@3.0.6': - dependencies: - '@types/d3-array': 3.2.2 - '@types/geojson': 7946.0.16 - - '@types/d3-delaunay@6.0.4': {} - - '@types/d3-dispatch@3.0.7': {} - - '@types/d3-drag@3.0.7': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-dsv@3.0.7': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-fetch@3.0.7': - dependencies: - '@types/d3-dsv': 3.0.7 - - '@types/d3-force@3.0.10': {} - - '@types/d3-format@3.0.4': {} - - '@types/d3-geo@3.1.0': - dependencies: - '@types/geojson': 7946.0.16 - - '@types/d3-hierarchy@3.1.7': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-polygon@3.0.2': {} - - '@types/d3-quadtree@3.0.6': {} - - '@types/d3-random@3.0.3': {} - - '@types/d3-scale-chromatic@3.1.0': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-selection@3.0.11': {} - - '@types/d3-shape@3.1.7': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time-format@4.0.3': {} - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/d3-transition@3.0.9': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-zoom@3.0.8': - dependencies: - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - - '@types/d3@7.4.3': - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-axis': 3.0.6 - '@types/d3-brush': 3.0.6 - '@types/d3-chord': 3.0.6 - '@types/d3-color': 3.1.3 - '@types/d3-contour': 3.0.6 - '@types/d3-delaunay': 6.0.4 - '@types/d3-dispatch': 3.0.7 - '@types/d3-drag': 3.0.7 - '@types/d3-dsv': 3.0.7 - '@types/d3-ease': 3.0.2 - '@types/d3-fetch': 3.0.7 - '@types/d3-force': 3.0.10 - '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.1.0 - '@types/d3-hierarchy': 3.1.7 - '@types/d3-interpolate': 3.0.4 - '@types/d3-path': 3.1.1 - '@types/d3-polygon': 3.0.2 - '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 - '@types/d3-scale': 4.0.9 - '@types/d3-scale-chromatic': 3.1.0 - '@types/d3-selection': 3.0.11 - '@types/d3-shape': 3.1.7 - '@types/d3-time': 3.0.4 - '@types/d3-time-format': 4.0.3 - '@types/d3-timer': 3.0.2 - '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@0.0.39': {} - - '@types/estree@1.0.8': {} - - '@types/geojson@7946.0.16': {} - - '@types/hast@2.3.10': - dependencies: - '@types/unist': 2.0.11 - - '@types/json-schema@7.0.15': {} - - '@types/lodash@4.17.20': {} - - '@types/luxon@3.7.1': {} - - '@types/node@24.7.2': - dependencies: - undici-types: 7.14.0 - - '@types/pako@2.0.4': {} - - '@types/pdfkit@0.17.3': - dependencies: - '@types/node': 24.7.2 - - '@types/phoenix@1.6.6': {} - - '@types/raf@3.4.3': - optional: true - - '@types/ramda@0.30.2': - dependencies: - types-ramda: 0.30.1 - - '@types/resolve@1.20.2': {} - - '@types/swagger-ui@5.21.1': {} - - '@types/trusted-types@2.0.7': {} - - '@types/unist@2.0.11': {} - - '@types/use-sync-external-store@0.0.6': {} - - '@types/ws@8.18.1': - dependencies: - '@types/node': 24.7.2 - - '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.0 - eslint: 9.37.0(jiti@2.6.1) - graphemer: 1.4.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.0 - debug: 4.4.3 - eslint: 9.37.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.46.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.46.0': - dependencies: - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/visitor-keys': 8.46.0 - - '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.37.0(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.46.0': {} - - '@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.46.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/visitor-keys': 8.46.0 - debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.3 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.46.0': - dependencies: - '@typescript-eslint/types': 8.46.0 - eslint-visitor-keys: 4.2.1 - - '@vercel/nft@0.30.2(rollup@2.79.2)': - dependencies: - '@mapbox/node-pre-gyp': 2.0.0 - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) - async-sema: 3.1.1 - bindings: 1.5.0 - estree-walker: 2.0.2 - glob: 10.4.5 - graceful-fs: 4.2.11 - node-gyp-build: 4.8.4 - picomatch: 4.0.3 - resolve-from: 5.0.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vite-pwa/sveltekit@1.0.0(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(workbox-build@7.3.0)(workbox-window@7.3.0)': - dependencies: - '@sveltejs/kit': 2.46.5(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.39.11)(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - kolorist: 1.8.0 - tinyglobby: 0.2.15 - vite-plugin-pwa: 1.0.3(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(workbox-build@7.3.0)(workbox-window@7.3.0) - transitivePeerDependencies: - - supports-color - - vite - - workbox-build - - workbox-window - - '@vitest/expect@3.2.4': - dependencies: - '@types/chai': 5.2.2 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 - - '@vitest/mocker@3.2.4(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.19 - optionalDependencies: - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - - '@vitest/pretty-format@3.2.4': - dependencies: - tinyrainbow: 2.0.0 - - '@vitest/runner@3.2.4': - dependencies: - '@vitest/utils': 3.2.4 - pathe: 2.0.3 - strip-literal: 3.1.0 - - '@vitest/snapshot@3.2.4': - dependencies: - '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.19 - pathe: 2.0.3 - - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 - - '@vitest/utils@3.2.4': - dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 - - '@yr/monotone-cubic-spline@1.0.3': {} - - abbrev@3.0.1: {} - - acorn-import-attributes@1.9.5(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - agent-base@7.1.4: {} - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-escapes@7.1.1: - dependencies: - environment: 1.1.0 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.3: {} - - apexcharts@4.7.0: - dependencies: - '@svgdotjs/svg.draggable.js': 3.0.6(@svgdotjs/svg.js@3.2.5) - '@svgdotjs/svg.filter.js': 3.0.9 - '@svgdotjs/svg.js': 3.2.5 - '@svgdotjs/svg.resize.js': 2.0.5(@svgdotjs/svg.js@3.2.5)(@svgdotjs/svg.select.js@4.0.3(@svgdotjs/svg.js@3.2.5)) - '@svgdotjs/svg.select.js': 4.0.3(@svgdotjs/svg.js@3.2.5) - '@yr/monotone-cubic-spline': 1.0.3 - - apg-lite@1.0.5: {} - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - argparse@2.0.1: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - assertion-error@2.0.1: {} - - async-function@1.0.0: {} - - async-sema@3.1.1: {} - - async@3.2.6: {} - - asynckit@0.4.0: {} - - at-least-node@1.0.0: {} - - autolinker@3.16.2: - dependencies: - tslib: 2.8.1 - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axios@1.12.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axobject-query@4.1.0: {} - - babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.4): - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - core-js-compat: 3.46.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - balanced-match@1.0.2: {} - - base64-arraybuffer@1.0.2: - optional: true - - base64-js@0.0.8: {} - - base64-js@1.5.1: {} - - baseline-browser-mapping@2.8.16: {} - - bin-links@5.0.0: - dependencies: - cmd-shim: 7.0.0 - npm-normalize-package-bin: 4.0.0 - proc-log: 5.0.0 - read-cmd-shim: 5.0.0 - write-file-atomic: 6.0.0 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - bits-ui@1.8.0(svelte@5.39.11): - dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/dom': 1.7.4 - '@internationalized/date': 3.10.0 - css.escape: 1.5.1 - esm-env: 1.2.2 - runed: 0.23.4(svelte@5.39.11) - svelte: 5.39.11 - svelte-toolbelt: 0.7.1(svelte@5.39.11) - tabbable: 6.2.0 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - brotli@1.3.3: - dependencies: - base64-js: 1.5.1 - - browserslist@4.26.3: - dependencies: - baseline-browser-mapping: 2.8.16 - caniuse-lite: 1.0.30001750 - electron-to-chromium: 1.5.234 - node-releases: 2.0.23 - update-browserslist-db: 1.1.3(browserslist@4.26.3) - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bufferutil@4.0.9: - dependencies: - node-gyp-build: 4.8.4 - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001750: {} - - canvas@3.2.0: - dependencies: - node-addon-api: 7.1.1 - prebuild-install: 7.1.3 - - canvg@3.0.11: - dependencies: - '@babel/runtime': 7.28.4 - '@types/raf': 3.4.3 - core-js: 3.46.0 - raf: 3.4.1 - regenerator-runtime: 0.13.11 - rgbcolor: 1.0.1 - stackblur-canvas: 2.7.0 - svg-pathdata: 6.0.3 - optional: true - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - character-entities-legacy@1.1.4: {} - - character-entities@1.2.4: {} - - character-reference-invalid@1.1.4: {} - - chart.js@4.5.1: - dependencies: - '@kurkle/color': 0.3.4 - - check-error@2.1.1: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@1.1.4: {} - - chownr@3.0.0: {} - - classnames@2.5.1: {} - - cli-color@2.0.4: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-iterator: 2.0.3 - memoizee: 0.4.17 - timers-ext: 0.1.8 - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@5.1.0: - dependencies: - slice-ansi: 7.1.2 - string-width: 8.1.0 - - clone@2.1.2: {} - - clsx@2.1.1: {} - - cmd-shim@7.0.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - colorette@2.0.20: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - comma-separated-tokens@1.0.8: {} - - commander@14.0.1: {} - - commander@2.20.3: {} - - commander@7.2.0: {} - - common-tags@1.8.2: {} - - concat-map@0.0.1: {} - - consola@3.4.2: {} - - convert-source-map@2.0.0: {} - - cookie@0.6.0: {} - - cookie@1.0.2: {} - - copy-to-clipboard@3.3.3: - dependencies: - toggle-selection: 1.0.6 - - core-js-compat@3.46.0: - dependencies: - browserslist: 4.26.3 - - core-js-pure@3.46.0: {} - - core-js@3.46.0: - optional: true - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - crypto-js@4.2.0: {} - - crypto-random-string@2.0.0: {} - - css-line-break@2.1.0: - dependencies: - utrie: 1.0.2 - optional: true - - css.escape@1.5.1: {} - - cssesc@3.0.0: {} - - cssstyle@4.6.0: - dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-axis@3.0.0: {} - - d3-brush@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3-chord@3.0.1: - dependencies: - d3-path: 3.1.0 - - d3-color@3.1.0: {} - - d3-contour@4.0.2: - dependencies: - d3-array: 3.2.4 - - d3-delaunay@6.0.4: - dependencies: - delaunator: 5.0.1 - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-dsv@3.0.1: - dependencies: - commander: 7.2.0 - iconv-lite: 0.6.3 - rw: 1.3.3 - - d3-ease@3.0.1: {} - - d3-fetch@3.0.1: - dependencies: - d3-dsv: 3.0.1 - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-format@3.1.0: {} - - d3-geo@3.1.1: - dependencies: - d3-array: 3.2.4 - - d3-hierarchy@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-polygon@3.0.1: {} - - d3-quadtree@3.0.1: {} - - d3-random@3.0.1: {} - - d3-scale-chromatic@3.1.0: - dependencies: - d3-color: 3.1.0 - d3-interpolate: 3.0.1 - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.0 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-selection@3.0.0: {} - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3@7.9.0: - dependencies: - d3-array: 3.2.4 - d3-axis: 3.0.0 - d3-brush: 3.0.0 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - d3-delaunay: 6.0.4 - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-dsv: 3.0.1 - d3-ease: 3.0.1 - d3-fetch: 3.0.1 - d3-force: 3.0.0 - d3-format: 3.1.0 - d3-geo: 3.1.1 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-path: 3.1.0 - d3-polygon: 3.0.1 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - d3-timer: 3.0.1 - d3-transition: 3.0.1(d3-selection@3.0.0) - d3-zoom: 3.0.0 - - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - - data-uri-to-buffer@4.0.1: {} - - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-eql@5.0.2: {} - - deep-extend@0.6.0: {} - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - delaunator@5.0.1: - dependencies: - robust-predicates: 3.0.2 - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - devalue@5.3.2: {} - - dfa@1.2.0: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dompurify@3.2.6: - optionalDependencies: - '@types/trusted-types': 2.0.7 - - dompurify@3.2.7: - optionalDependencies: - '@types/trusted-types': 2.0.7 - optional: true - - drange@1.1.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ejs@3.1.10: - dependencies: - jake: 10.9.4 - - electron-to-chromium@1.5.234: {} - - emoji-regex@10.6.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - enhanced-resolve@5.18.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - - entities@6.0.1: {} - - environment@1.1.0: {} - - es-abstract@1.24.0: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - - es6-weak-map@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - - esbuild@0.19.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - - esbuild@0.25.10: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.10 - '@esbuild/android-arm': 0.25.10 - '@esbuild/android-arm64': 0.25.10 - '@esbuild/android-x64': 0.25.10 - '@esbuild/darwin-arm64': 0.25.10 - '@esbuild/darwin-x64': 0.25.10 - '@esbuild/freebsd-arm64': 0.25.10 - '@esbuild/freebsd-x64': 0.25.10 - '@esbuild/linux-arm': 0.25.10 - '@esbuild/linux-arm64': 0.25.10 - '@esbuild/linux-ia32': 0.25.10 - '@esbuild/linux-loong64': 0.25.10 - '@esbuild/linux-mips64el': 0.25.10 - '@esbuild/linux-ppc64': 0.25.10 - '@esbuild/linux-riscv64': 0.25.10 - '@esbuild/linux-s390x': 0.25.10 - '@esbuild/linux-x64': 0.25.10 - '@esbuild/netbsd-arm64': 0.25.10 - '@esbuild/netbsd-x64': 0.25.10 - '@esbuild/openbsd-arm64': 0.25.10 - '@esbuild/openbsd-x64': 0.25.10 - '@esbuild/openharmony-arm64': 0.25.10 - '@esbuild/sunos-x64': 0.25.10 - '@esbuild/win32-arm64': 0.25.10 - '@esbuild/win32-ia32': 0.25.10 - '@esbuild/win32-x64': 0.25.10 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@10.1.8(eslint@9.37.0(jiti@2.6.1)): - dependencies: - eslint: 9.37.0(jiti@2.6.1) - - eslint-plugin-svelte@3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) - '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 9.37.0(jiti@2.6.1) - esutils: 2.0.3 - globals: 16.4.0 - known-css-properties: 0.37.0 - postcss: 8.5.6 - postcss-load-config: 3.1.4(postcss@8.5.6) - postcss-safe-parser: 7.0.1(postcss@8.5.6) - semver: 7.7.3 - svelte-eslint-parser: 1.3.3(svelte@5.39.11) - optionalDependencies: - svelte: 5.39.11 - transitivePeerDependencies: - - ts-node - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.37.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.4.0 - '@eslint/core': 0.16.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.37.0 - '@eslint/plugin-kit': 0.4.0 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - esm-env@1.2.2: {} - - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - - espree@10.4.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrap@2.1.0: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@1.0.1: {} - - estree-walker@2.0.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - esutils@2.0.3: {} - - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - - eventemitter3@5.0.1: {} - - expand-template@2.0.3: {} - - expect-type@1.2.2: {} - - ext@1.7.0: - dependencies: - type: 2.7.3 - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-patch@3.1.1: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-png@6.4.0: - dependencies: - '@types/pako': 2.0.4 - iobuffer: 5.4.0 - pako: 2.1.0 - - fast-uri@3.1.0: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fault@1.0.4: - dependencies: - format: 0.2.2 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} - fflate@0.8.2: {} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - file-entry-cache@8.0.0: +snapshots: + + '@cropwatchdevelopment/cwui@0.1.73(svelte@5.53.0)': dependencies: - flat-cache: 4.0.1 + svelte: 5.53.0 - file-uri-to-path@1.0.0: {} + '@esbuild/aix-ppc64@0.25.12': + optional: true - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 + '@esbuild/aix-ppc64@0.27.3': + optional: true - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 + '@esbuild/android-arm64@0.25.12': + optional: true - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 + '@esbuild/android-arm64@0.27.3': + optional: true - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 + '@esbuild/android-arm@0.25.12': + optional: true - flatted@3.3.3: {} + '@esbuild/android-arm@0.27.3': + optional: true - follow-redirects@1.15.11: {} + '@esbuild/android-x64@0.25.12': + optional: true - fontkit@2.0.4: - dependencies: - '@swc/helpers': 0.5.17 - brotli: 1.3.3 - clone: 2.1.2 - dfa: 1.2.0 - fast-deep-equal: 3.1.3 - restructure: 3.0.2 - tiny-inflate: 1.0.3 - unicode-properties: 1.4.1 - unicode-trie: 2.0.0 + '@esbuild/android-x64@0.27.3': + optional: true - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 + '@esbuild/darwin-arm64@0.25.12': + optional: true - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 + '@esbuild/darwin-arm64@0.27.3': + optional: true - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 + '@esbuild/darwin-x64@0.25.12': + optional: true - format@0.2.2: {} + '@esbuild/darwin-x64@0.27.3': + optional: true - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 + '@esbuild/freebsd-arm64@0.25.12': + optional: true - fs-constants@1.0.0: {} + '@esbuild/freebsd-arm64@0.27.3': + optional: true - fs-extra@9.1.0: - dependencies: - at-least-node: 1.0.0 - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 + '@esbuild/freebsd-x64@0.25.12': + optional: true - fs.realpath@1.0.0: {} + '@esbuild/freebsd-x64@0.27.3': + optional: true - fsevents@2.3.2: + '@esbuild/linux-arm64@0.25.12': optional: true - fsevents@2.3.3: + '@esbuild/linux-arm64@0.27.3': optional: true - function-bind@1.1.2: {} + '@esbuild/linux-arm@0.25.12': + optional: true - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 + '@esbuild/linux-arm@0.27.3': + optional: true - functions-have-names@1.2.3: {} + '@esbuild/linux-ia32@0.25.12': + optional: true - generator-function@2.0.1: {} + '@esbuild/linux-ia32@0.27.3': + optional: true - gensync@1.0.0-beta.2: {} + '@esbuild/linux-loong64@0.25.12': + optional: true - get-east-asian-width@1.4.0: {} + '@esbuild/linux-loong64@0.27.3': + optional: true - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 + '@esbuild/linux-mips64el@0.25.12': + optional: true - get-own-enumerable-property-symbols@3.0.2: {} + '@esbuild/linux-mips64el@0.27.3': + optional: true - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + '@esbuild/linux-ppc64@0.25.12': + optional: true - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 + '@esbuild/linux-ppc64@0.27.3': + optional: true - github-from-package@0.0.0: {} + '@esbuild/linux-riscv64@0.25.12': + optional: true - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 + '@esbuild/linux-riscv64@0.27.3': + optional: true - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 + '@esbuild/linux-s390x@0.25.12': + optional: true - glob@10.4.5: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 + '@esbuild/linux-s390x@0.27.3': + optional: true - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 + '@esbuild/linux-x64@0.25.12': + optional: true - globals@14.0.0: {} + '@esbuild/linux-x64@0.27.3': + optional: true - globals@16.4.0: {} + '@esbuild/netbsd-arm64@0.25.12': + optional: true - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 + '@esbuild/netbsd-arm64@0.27.3': + optional: true - globalyzer@0.1.0: {} + '@esbuild/netbsd-x64@0.25.12': + optional: true - globrex@0.1.2: {} + '@esbuild/netbsd-x64@0.27.3': + optional: true - gopd@1.2.0: {} + '@esbuild/openbsd-arm64@0.25.12': + optional: true - graceful-fs@4.2.11: {} + '@esbuild/openbsd-arm64@0.27.3': + optional: true - graphemer@1.4.0: {} + '@esbuild/openbsd-x64@0.25.12': + optional: true - has-bigints@1.1.0: {} + '@esbuild/openbsd-x64@0.27.3': + optional: true - has-flag@4.0.0: {} + '@esbuild/openharmony-arm64@0.25.12': + optional: true - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 + '@esbuild/openharmony-arm64@0.27.3': + optional: true - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 + '@esbuild/sunos-x64@0.25.12': + optional: true - has-symbols@1.1.0: {} + '@esbuild/sunos-x64@0.27.3': + optional: true - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 + '@esbuild/win32-arm64@0.25.12': + optional: true - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 + '@esbuild/win32-arm64@0.27.3': + optional: true - hast-util-parse-selector@2.2.5: {} + '@esbuild/win32-ia32@0.25.12': + optional: true - hastscript@6.0.0: - dependencies: - '@types/hast': 2.3.10 - comma-separated-tokens: 1.0.8 - hast-util-parse-selector: 2.2.5 - property-information: 5.6.0 - space-separated-tokens: 1.1.5 + '@esbuild/win32-ia32@0.27.3': + optional: true - highlight.js@10.7.3: {} + '@esbuild/win32-x64@0.25.12': + optional: true - highlightjs-vue@1.0.0: {} + '@esbuild/win32-x64@0.27.3': + optional: true - html-encoding-sniffer@4.0.0: + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: - whatwg-encoding: 3.1.1 + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 - html2canvas@1.4.1: - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - optional: true + '@eslint-community/regexpp@4.12.2': {} - http-proxy-agent@7.0.2: + '@eslint/compat@2.0.2(eslint@9.39.2(jiti@2.6.1))': dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + '@eslint/core': 1.1.0 + optionalDependencies: + eslint: 9.39.2(jiti@2.6.1) - https-proxy-agent@7.0.6: + '@eslint/config-array@0.21.1': dependencies: - agent-base: 7.1.4 + '@eslint/object-schema': 2.1.7 debug: 4.4.3 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color - husky@8.0.3: {} - - iconv-lite@0.6.3: + '@eslint/config-helpers@0.4.2': dependencies: - safer-buffer: 2.1.2 - - idb@7.1.1: {} - - ieee754@1.2.1: {} - - ignore@5.3.2: {} + '@eslint/core': 0.17.0 - ignore@7.0.5: {} + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 - immutable@3.8.2: {} + '@eslint/core@1.1.0': + dependencies: + '@types/json-schema': 7.0.15 - import-fresh@3.3.1: + '@eslint/eslintrc@3.3.3': dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color - imurmurhash@0.1.4: {} + '@eslint/js@9.39.2': {} - indent-string@4.0.0: {} + '@eslint/object-schema@2.1.7': {} - inflight@1.0.6: + '@eslint/plugin-kit@0.4.1': dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} + '@eslint/core': 0.17.0 + levn: 0.4.1 - ini@1.3.8: {} + '@fontsource/fira-mono@5.2.7': {} - inline-style-parser@0.2.4: {} + '@humanfs/core@0.19.1': {} - internal-slot@1.1.0: + '@humanfs/node@0.16.7': dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 - internmap@2.0.3: {} + '@humanwhocodes/module-importer@1.0.1': {} - intl-messageformat@10.7.18: - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/icu-messageformat-parser': 2.11.4 - tslib: 2.8.1 + '@humanwhocodes/retry@0.4.3': {} - invariant@2.2.4: + '@inlang/paraglide-js@2.15.0': dependencies: - loose-envify: 1.4.0 - - iobuffer@5.4.0: {} - - is-alphabetical@1.0.4: {} + '@inlang/recommend-sherlock': 0.2.1 + '@inlang/sdk': 2.8.0 + commander: 11.1.0 + consola: 3.4.0 + json5: 2.2.3 + unplugin: 2.3.11 + urlpattern-polyfill: 10.1.0 + transitivePeerDependencies: + - babel-plugin-macros - is-alphanumerical@1.0.4: + '@inlang/recommend-sherlock@0.2.1': dependencies: - is-alphabetical: 1.0.4 - is-decimal: 1.0.4 + comment-json: 4.6.2 - is-array-buffer@3.0.5: + '@inlang/sdk@2.8.0': dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 + '@lix-js/sdk': 0.4.7 + '@sinclair/typebox': 0.31.28 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 13.0.0 + transitivePeerDependencies: + - babel-plugin-macros - is-async-function@2.1.1: + '@isaacs/fs-minipass@4.0.1': dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 + minipass: 7.1.3 - is-bigint@1.1.0: + '@jridgewell/gen-mapping@0.3.13': dependencies: - has-bigints: 1.1.0 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - is-boolean-object@1.2.2: + '@jridgewell/remapping@2.3.5': dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - is-callable@1.2.7: {} + '@jridgewell/resolve-uri@3.1.2': {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 + '@jridgewell/sourcemap-codec@1.5.5': {} - is-data-view@1.0.2: + '@jridgewell/trace-mapping@0.3.31': dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - is-date-object@1.1.0: + '@lix-js/sdk@0.4.7': dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-decimal@1.0.4: {} + '@lix-js/server-protocol-schema': 0.1.1 + dedent: 1.5.1 + human-id: 4.1.3 + js-sha256: 0.11.1 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 10.0.0 + transitivePeerDependencies: + - babel-plugin-macros - is-extglob@2.1.1: {} + '@lix-js/server-protocol-schema@0.1.1': {} - is-finalizationregistry@1.1.1: + '@mapbox/node-pre-gyp@2.0.3': dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.7.4 + tar: 7.5.9 + transitivePeerDependencies: + - encoding + - supports-color - is-fullwidth-code-point@5.1.0: + '@neoconfetti/svelte@2.2.2(svelte@5.53.0)': dependencies: - get-east-asian-width: 1.4.0 + svelte: 5.53.0 - is-generator-function@1.1.2: + '@playwright/test@1.58.2': dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 + playwright: 1.58.2 - is-glob@4.0.3: + '@polka/url@1.0.0-next.29': {} + + '@rollup/pluginutils@5.3.0(rollup@4.57.1)': dependencies: - is-extglob: 2.1.1 + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.57.1 - is-hexadecimal@1.0.4: {} + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true - is-map@2.0.3: {} + '@rollup/rollup-android-arm64@4.57.1': + optional: true - is-module@1.0.0: {} + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true - is-negative-zero@2.0.3: {} + '@rollup/rollup-darwin-x64@4.57.1': + optional: true - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true - is-number@7.0.0: {} + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true - is-obj@1.0.1: {} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true - is-potential-custom-element-name@1.0.1: {} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true - is-promise@2.2.2: {} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true - is-regexp@1.0.0: {} + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true - is-set@2.0.3: {} + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true - is-stream@2.0.1: {} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true - is-weakmap@2.0.2: {} + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true - isarray@2.0.5: {} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true - isexe@2.0.0: {} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true - jiti@2.6.1: {} + '@sinclair/typebox@0.31.28': {} - jpeg-exif@1.1.4: {} + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} - js-file-download@0.4.12: {} + '@standard-schema/spec@1.1.0': {} - js-tokens@4.0.0: {} + '@supabase/auth-js@2.98.0': + dependencies: + tslib: 2.8.1 - js-tokens@9.0.1: {} + '@supabase/functions-js@2.98.0': + dependencies: + tslib: 2.8.1 - js-yaml@4.1.0: + '@supabase/postgrest-js@2.98.0': dependencies: - argparse: 2.0.1 + tslib: 2.8.1 - jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5): + '@supabase/realtime-js@2.98.0': dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 3.2.0 + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - - supports-color - utf-8-validate - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-schema@0.4.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsonpointer@5.0.1: {} + '@supabase/storage-js@2.98.0': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 - jspdf@3.0.3: + '@supabase/supabase-js@2.98.0': dependencies: - '@babel/runtime': 7.28.4 - fast-png: 6.4.0 - fflate: 0.8.2 - optionalDependencies: - canvg: 3.0.11 - core-js: 3.46.0 - dompurify: 3.2.7 - html2canvas: 1.4.1 + '@supabase/auth-js': 2.98.0 + '@supabase/functions-js': 2.98.0 + '@supabase/postgrest-js': 2.98.0 + '@supabase/realtime-js': 2.98.0 + '@supabase/storage-js': 2.98.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - keyv@4.5.4: + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': dependencies: - json-buffer: 3.0.1 + acorn: 8.16.0 - kleur@4.1.5: {} + '@sveltejs/adapter-vercel@6.3.2(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(rollup@4.57.1)': + dependencies: + '@sveltejs/kit': 2.52.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vercel/nft': 1.3.1(rollup@4.57.1) + esbuild: 0.25.12 + transitivePeerDependencies: + - encoding + - rollup + - supports-color - known-css-properties@0.37.0: {} + '@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.3 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.53.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) + optionalDependencies: + typescript: 5.9.3 - kolorist@1.8.0: {} + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + obug: 2.1.1 + svelte: 5.53.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) - leaflet@2.0.0-alpha: {} + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.53.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) + vitefu: 1.1.1(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) - leven@3.1.0: {} + '@tailwindcss/forms@0.5.11(tailwindcss@4.2.0)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.2.0 - levn@0.4.1: + '@tailwindcss/node@4.2.0': dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.0 - lightningcss-darwin-arm64@1.30.1: + '@tailwindcss/oxide-android-arm64@4.2.0': optional: true - lightningcss-darwin-x64@1.30.1: + '@tailwindcss/oxide-darwin-arm64@4.2.0': optional: true - lightningcss-freebsd-x64@1.30.1: + '@tailwindcss/oxide-darwin-x64@4.2.0': optional: true - lightningcss-linux-arm-gnueabihf@1.30.1: + '@tailwindcss/oxide-freebsd-x64@4.2.0': optional: true - lightningcss-linux-arm64-gnu@1.30.1: + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': optional: true - lightningcss-linux-arm64-musl@1.30.1: + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': optional: true - lightningcss-linux-x64-gnu@1.30.1: + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': optional: true - lightningcss-linux-x64-musl@1.30.1: + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': optional: true - lightningcss-win32-arm64-msvc@1.30.1: + '@tailwindcss/oxide-linux-x64-musl@4.2.0': optional: true - lightningcss-win32-x64-msvc@1.30.1: + '@tailwindcss/oxide-wasm32-wasi@4.2.0': optional: true - lightningcss@1.30.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-darwin-arm64: 1.30.1 - lightningcss-darwin-x64: 1.30.1 - lightningcss-freebsd-x64: 1.30.1 - lightningcss-linux-arm-gnueabihf: 1.30.1 - lightningcss-linux-arm64-gnu: 1.30.1 - lightningcss-linux-arm64-musl: 1.30.1 - lightningcss-linux-x64-gnu: 1.30.1 - lightningcss-linux-x64-musl: 1.30.1 - lightningcss-win32-arm64-msvc: 1.30.1 - lightningcss-win32-x64-msvc: 1.30.1 + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + optional: true - lilconfig@2.1.0: {} + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + optional: true - linebreak@1.1.0: + '@tailwindcss/oxide@4.2.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-x64': 4.2.0 + '@tailwindcss/oxide-freebsd-x64': 4.2.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-x64-musl': 4.2.0 + '@tailwindcss/oxide-wasm32-wasi': 4.2.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.0)': dependencies: - base64-js: 0.0.8 - unicode-trie: 2.0.0 + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.0 - lint-staged@16.2.4: + '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: - commander: 14.0.1 - listr2: 9.0.4 - micromatch: 4.0.8 - nano-spawn: 2.0.0 - pidtree: 0.6.0 - string-argv: 0.3.2 - yaml: 2.8.1 + '@tailwindcss/node': 4.2.0 + '@tailwindcss/oxide': 4.2.0 + tailwindcss: 4.2.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) - listr2@9.0.4: + '@testing-library/svelte-core@1.0.0(svelte@5.53.0)': dependencies: - cli-truncate: 5.1.0 - colorette: 2.0.20 - eventemitter3: 5.0.1 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - - locate-character@3.0.0: {} + svelte: 5.53.0 - locate-path@6.0.0: + '@types/chai@5.2.3': dependencies: - p-locate: 5.0.0 + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 - lodash.debounce@4.0.8: {} + '@types/cookie@0.6.0': {} - lodash.merge@4.6.2: {} + '@types/deep-eql@4.0.2': {} - lodash.sortby@4.7.0: {} + '@types/estree@1.0.8': {} - lodash@4.17.21: {} + '@types/json-schema@7.0.15': {} - log-update@6.1.0: + '@types/node@22.19.11': dependencies: - ansi-escapes: 7.1.1 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.1.2 - wrap-ansi: 9.0.2 + undici-types: 6.21.0 - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 + '@types/phoenix@1.6.7': {} - loupe@3.2.1: {} + '@types/trusted-types@2.0.7': {} - lowlight@1.20.0: + '@types/ws@8.18.1': dependencies: - fault: 1.0.4 - highlight.js: 10.7.3 - - lru-cache@10.4.3: {} + '@types/node': 22.19.11 - lru-cache@5.1.1: + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - yallist: 3.1.1 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - lru-queue@0.1.0: + '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - es5-ext: 0.10.64 - - luxon@3.7.2: {} - - lz-string@1.5.0: {} + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - magic-string@0.25.9: + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - sourcemap-codec: 1.4.8 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - magic-string@0.30.19: + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 - math-intrinsics@1.1.0: {} - - memoizee@0.4.17: + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-weak-map: 2.0.3 - event-emitter: 0.3.5 - is-promise: 2.2.2 - lru-queue: 0.1.0 - next-tick: 1.1.0 - timers-ext: 0.1.8 - - merge2@1.4.1: {} + typescript: 5.9.3 - micromatch@4.0.8: + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - mime-db@1.52.0: {} + '@typescript-eslint/types@8.56.0': {} - mime-types@2.1.35: + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - mime-db: 1.52.0 - - mimic-function@5.0.1: {} - - mimic-response@3.1.0: {} - - min-indent@1.0.1: {} - - mini-svg-data-uri@1.4.4: {} + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - minim@0.23.8: + '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - lodash: 4.17.21 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - minimatch@3.1.2: + '@typescript-eslint/visitor-keys@8.56.0': dependencies: - brace-expansion: 1.1.12 + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 - minimatch@5.1.6: + '@vercel/nft@1.3.1(rollup@4.57.1)': dependencies: - brace-expansion: 2.0.2 + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.3 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color - minimatch@7.4.6: + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)': dependencies: - brace-expansion: 2.0.2 + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + playwright: 1.58.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.11)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite - minimatch@9.0.5: + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)': dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - minipass@7.1.2: {} + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.11)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite - minizlib@3.1.0: + '@vitest/expect@4.0.18': dependencies: - minipass: 7.1.2 - - mkdirp-classic@0.5.3: {} - - mri@1.2.0: {} - - mrmime@2.0.1: {} - - ms@2.1.3: {} - - nano-spawn@2.0.0: {} - - nanoid@3.3.11: {} + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 - napi-build-utils@2.0.0: {} - - natural-compare@1.4.0: {} - - neotraverse@0.6.18: {} - - next-tick@1.1.0: {} - - node-abi@3.78.0: + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: - semver: 7.7.3 + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) - node-abort-controller@3.1.1: {} + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 - node-addon-api@7.1.1: {} + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 - node-addon-api@8.5.0: - optional: true + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 - node-domexception@1.0.0: {} + '@vitest/spy@4.0.18': {} - node-fetch-commonjs@3.3.2: + '@vitest/utils@4.0.18': dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 - node-fetch@2.7.0: + abbrev@3.0.1: {} + + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - whatwg-url: 5.0.0 + acorn: 8.16.0 - node-fetch@3.3.2: + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 + acorn: 8.16.0 - node-gyp-build@4.8.4: {} + acorn@8.16.0: {} - node-releases@2.0.23: {} + agent-base@7.1.4: {} - nopt@8.1.0: + ajv@6.12.6: dependencies: - abbrev: 3.0.1 - - npm-normalize-package-bin@4.0.0: {} + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 - nwsapi@2.2.22: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 - object-assign@4.1.1: {} + argparse@2.0.1: {} - object-inspect@1.13.4: {} + aria-query@5.3.2: {} - object-keys@1.1.1: {} + array-timsort@1.0.3: {} - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 + assertion-error@2.0.1: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 + async-sema@3.1.1: {} - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 + axobject-query@4.1.0: {} - openapi-path-templating@2.2.1: - dependencies: - apg-lite: 1.0.5 + balanced-match@1.0.2: {} - openapi-server-url-templating@1.3.0: - dependencies: - apg-lite: 1.0.5 + balanced-match@4.0.3: {} - optionator@0.9.4: + bindings@1.5.0: dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 + file-uri-to-path: 1.0.0 - own-keys@1.0.1: + brace-expansion@1.1.12: dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 + balanced-match: 1.0.2 + concat-map: 0.0.1 - p-limit@3.1.0: + brace-expansion@2.0.2: dependencies: - yocto-queue: 0.1.0 + balanced-match: 1.0.2 - p-locate@5.0.0: + brace-expansion@5.0.2: dependencies: - p-limit: 3.1.0 - - package-json-from-dist@1.0.1: {} + balanced-match: 4.0.3 - pako@0.2.9: {} - - pako@2.1.0: {} + callsites@3.1.0: {} - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 + chai@6.2.2: {} - parse-entities@2.0.0: + chalk@4.1.2: dependencies: - character-entities: 1.2.4 - character-entities-legacy: 1.1.4 - character-reference-invalid: 1.1.4 - is-alphanumerical: 1.0.4 - is-decimal: 1.0.4 - is-hexadecimal: 1.0.4 + ansi-styles: 4.3.0 + supports-color: 7.2.0 - parse5@7.3.0: + chokidar@4.0.3: dependencies: - entities: 6.0.1 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} + readdirp: 4.1.2 - path-key@3.1.1: {} + chownr@3.0.0: {} - path-parse@1.0.7: {} + clsx@2.1.1: {} - path-scurry@1.11.1: + color-convert@2.0.1: dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 + color-name: 1.1.4 - pathe@2.0.3: {} + color-name@1.1.4: {} - pathval@2.0.1: {} + commander@11.1.0: {} - pdfkit@0.17.2: + comment-json@4.6.2: dependencies: - crypto-js: 4.2.0 - fontkit: 2.0.4 - jpeg-exif: 1.1.4 - linebreak: 1.1.0 - png-js: 1.0.0 + array-timsort: 1.0.3 + esprima: 4.0.1 - performance-now@2.1.0: - optional: true - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} + concat-map@0.0.1: {} - picomatch@4.0.3: {} + consola@3.4.0: {} - pidtree@0.6.0: {} + consola@3.4.2: {} - playwright-core@1.56.0: {} + cookie@0.6.0: {} - playwright@1.56.0: + cross-spawn@7.0.6: dependencies: - playwright-core: 1.56.0 - optionalDependencies: - fsevents: 2.3.2 - - png-js@1.0.0: {} + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 - possible-typed-array-names@1.1.0: {} + cssesc@3.0.0: {} - postcss-load-config@3.1.4(postcss@8.5.6): + debug@4.4.3: dependencies: - lilconfig: 2.1.0 - yaml: 1.10.2 - optionalDependencies: - postcss: 8.5.6 + ms: 2.1.3 - postcss-safe-parser@7.0.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 + dedent@1.5.1: {} - postcss-scss@4.0.9(postcss@8.5.6): - dependencies: - postcss: 8.5.6 + deep-is@0.1.4: {} - postcss-selector-parser@6.0.10: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 + deepmerge@4.3.1: {} - postcss-selector-parser@7.1.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 + detect-libc@2.1.2: {} + + devalue@5.6.3: {} - postcss@8.5.6: + enhanced-resolve@5.19.0: dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 + graceful-fs: 4.2.11 + tapable: 2.3.0 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.78.0 - pump: 3.0.3 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 + es-module-lexer@1.7.0: {} - prelude-ls@1.2.1: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.39.11): + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: - prettier: 3.6.2 - svelte: 5.39.11 + eslint: 9.39.2(jiti@2.6.1) - prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.39.11))(prettier@3.6.2): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.53.0): dependencies: - prettier: 3.6.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 9.39.2(jiti@2.6.1) + esutils: 2.0.3 + globals: 16.5.0 + known-css-properties: 0.37.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + postcss-safe-parser: 7.0.1(postcss@8.5.6) + semver: 7.7.4 + svelte-eslint-parser: 1.4.1(svelte@5.53.0) optionalDependencies: - prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.39.11) + svelte: 5.53.0 + transitivePeerDependencies: + - ts-node + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 - prettier@3.6.2: {} + eslint-visitor-keys@3.4.3: {} - pretty-bytes@5.6.0: {} + eslint-visitor-keys@4.2.1: {} - pretty-bytes@6.1.1: {} + eslint-visitor-keys@5.0.0: {} - pretty-format@27.5.1: + eslint@9.39.2(jiti@2.6.1): dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color - prismjs@1.27.0: {} + esm-env@1.2.2: {} - prismjs@1.30.0: {} + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 - proc-log@5.0.0: {} + esprima@4.0.1: {} - process@0.11.10: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 - prop-types@15.8.1: + esrap@2.2.3: dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 + '@jridgewell/sourcemap-codec': 1.5.5 - property-information@5.6.0: + esrecurse@4.3.0: dependencies: - xtend: 4.0.2 + estraverse: 5.3.0 + + estraverse@5.3.0: {} - proxy-from-env@1.1.0: {} + estree-walker@2.0.2: {} - pump@3.0.3: + estree-walker@3.0.3: dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 + '@types/estree': 1.0.8 - punycode@2.3.1: {} + esutils@2.0.3: {} - qs@6.14.0: - dependencies: - side-channel: 1.1.0 + expect-type@1.3.0: {} - querystringify@2.2.0: {} + fast-deep-equal@3.1.3: {} - queue-microtask@1.2.3: {} + fast-json-stable-stringify@2.1.0: {} - raf@3.4.1: - dependencies: - performance-now: 2.1.0 - optional: true + fast-levenshtein@2.0.6: {} - ramda-adjunct@5.1.0(ramda@0.30.1): + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: dependencies: - ramda: 0.30.1 + flat-cache: 4.0.1 - ramda@0.30.1: {} + file-uri-to-path@1.0.0: {} - randexp@0.5.3: + find-up@5.0.0: dependencies: - drange: 1.1.1 - ret: 0.2.2 + locate-path: 6.0.0 + path-exists: 4.0.0 - randombytes@2.1.0: + flat-cache@4.0.1: dependencies: - safe-buffer: 5.2.1 + flatted: 3.3.3 + keyv: 4.5.4 - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 + flatted@3.3.3: {} - react-copy-to-clipboard@5.1.0(react@19.2.0): - dependencies: - copy-to-clipboard: 3.3.3 - prop-types: 15.8.1 - react: 19.2.0 + fsevents@2.3.2: + optional: true - react-debounce-input@3.3.0(react@19.2.0): - dependencies: - lodash.debounce: 4.0.8 - prop-types: 15.8.1 - react: 19.2.0 + fsevents@2.3.3: + optional: true - react-dom@19.2.0(react@19.2.0): + glob-parent@6.0.2: dependencies: - react: 19.2.0 - scheduler: 0.27.0 + is-glob: 4.0.3 - react-immutable-proptypes@2.2.0(immutable@3.8.2): + glob@13.0.6: dependencies: - immutable: 3.8.2 - invariant: 2.2.4 + minimatch: 10.2.2 + minipass: 7.1.3 + path-scurry: 2.0.2 - react-immutable-pure-component@2.2.2(immutable@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - immutable: 3.8.2 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + globals@14.0.0: {} - react-inspector@6.0.2(react@19.2.0): - dependencies: - react: 19.2.0 + globals@16.5.0: {} - react-is@16.13.1: {} + globals@17.3.0: {} - react-is@17.0.2: {} + graceful-fs@4.2.11: {} - react-redux@9.2.0(react@19.2.0)(redux@5.0.1): - dependencies: - '@types/use-sync-external-store': 0.0.6 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) - optionalDependencies: - redux: 5.0.1 + has-flag@4.0.0: {} - react-syntax-highlighter@15.6.6(react@19.2.0): + https-proxy-agent@7.0.6: dependencies: - '@babel/runtime': 7.28.4 - highlight.js: 10.7.3 - highlightjs-vue: 1.0.0 - lowlight: 1.20.0 - prismjs: 1.30.0 - react: 19.2.0 - refractor: 3.6.0 + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - react@19.2.0: {} + human-id@4.1.3: {} - read-cmd-shim@5.0.0: {} + iceberg-js@0.8.1: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 + ignore@5.3.2: {} - readdirp@4.1.2: {} + ignore@7.0.5: {} - redent@3.0.0: + import-fresh@3.3.1: dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 + parent-module: 1.0.1 + resolve-from: 4.0.0 - redux-immutable@4.0.0(immutable@3.8.2): - dependencies: - immutable: 3.8.2 + imurmurhash@0.1.4: {} - redux@5.0.1: {} + is-extglob@2.1.1: {} - reflect.getprototypeof@1.0.10: + is-glob@4.0.3: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 + is-extglob: 2.1.1 - refractor@3.6.0: + is-reference@3.0.3: dependencies: - hastscript: 6.0.0 - parse-entities: 2.0.0 - prismjs: 1.27.0 + '@types/estree': 1.0.8 - regenerate-unicode-properties@10.2.2: - dependencies: - regenerate: 1.4.2 + isexe@2.0.0: {} - regenerate@1.4.2: {} + jiti@2.6.1: {} - regenerator-runtime@0.13.11: - optional: true + js-sha256@0.11.1: {} - regexp.prototype.flags@1.5.4: + js-yaml@4.1.1: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 + argparse: 2.0.1 - regexpu-core@6.4.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties: 10.2.2 - regjsgen: 0.8.0 - regjsparser: 0.13.0 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.2.1 + json-buffer@3.0.1: {} - regjsgen@0.8.0: {} + json-schema-traverse@0.4.1: {} - regjsparser@0.13.0: - dependencies: - jsesc: 3.1.0 + json-stable-stringify-without-jsonify@1.0.1: {} - remarkable@2.0.1: + json5@2.2.3: {} + + jwt-decode@4.0.0: {} + + keyv@4.5.4: dependencies: - argparse: 1.0.10 - autolinker: 3.16.2 + json-buffer: 3.0.1 - repeat-string@1.6.1: {} + kleur@4.1.5: {} - require-from-string@2.0.2: {} + known-css-properties@0.37.0: {} - requires-port@1.0.0: {} + kysely@0.27.6: {} - reselect@5.1.1: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 - resolve-from@4.0.0: {} + lightningcss-android-arm64@1.31.1: + optional: true - resolve-from@5.0.0: {} + lightningcss-darwin-arm64@1.31.1: + optional: true - resolve@1.22.10: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 + lightningcss-darwin-x64@1.31.1: + optional: true - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 + lightningcss-freebsd-x64@1.31.1: + optional: true - restructure@3.0.2: {} + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true - ret@0.2.2: {} + lightningcss-linux-arm64-gnu@1.31.1: + optional: true - reusify@1.1.0: {} + lightningcss-linux-arm64-musl@1.31.1: + optional: true - rfdc@1.4.1: {} + lightningcss-linux-x64-gnu@1.31.1: + optional: true - rgbcolor@1.0.1: + lightningcss-linux-x64-musl@1.31.1: optional: true - robust-predicates@3.0.2: {} + lightningcss-win32-arm64-msvc@1.31.1: + optional: true - rollup@2.79.2: - optionalDependencies: - fsevents: 2.3.3 + lightningcss-win32-x64-msvc@1.31.1: + optional: true - rollup@4.52.4: + lightningcss@1.31.1: dependencies: - '@types/estree': 1.0.8 + detect-libc: 2.1.2 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.52.4 - '@rollup/rollup-android-arm64': 4.52.4 - '@rollup/rollup-darwin-arm64': 4.52.4 - '@rollup/rollup-darwin-x64': 4.52.4 - '@rollup/rollup-freebsd-arm64': 4.52.4 - '@rollup/rollup-freebsd-x64': 4.52.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 - '@rollup/rollup-linux-arm-musleabihf': 4.52.4 - '@rollup/rollup-linux-arm64-gnu': 4.52.4 - '@rollup/rollup-linux-arm64-musl': 4.52.4 - '@rollup/rollup-linux-loong64-gnu': 4.52.4 - '@rollup/rollup-linux-ppc64-gnu': 4.52.4 - '@rollup/rollup-linux-riscv64-gnu': 4.52.4 - '@rollup/rollup-linux-riscv64-musl': 4.52.4 - '@rollup/rollup-linux-s390x-gnu': 4.52.4 - '@rollup/rollup-linux-x64-gnu': 4.52.4 - '@rollup/rollup-linux-x64-musl': 4.52.4 - '@rollup/rollup-openharmony-arm64': 4.52.4 - '@rollup/rollup-win32-arm64-msvc': 4.52.4 - '@rollup/rollup-win32-ia32-msvc': 4.52.4 - '@rollup/rollup-win32-x64-gnu': 4.52.4 - '@rollup/rollup-win32-x64-msvc': 4.52.4 - fsevents: 2.3.3 + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 - rrweb-cssom@0.8.0: {} + lilconfig@2.1.0: {} - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 + locate-character@3.0.0: {} - runed@0.23.4(svelte@5.39.11): + locate-path@6.0.0: dependencies: - esm-env: 1.2.2 - svelte: 5.39.11 + p-locate: 5.0.0 - rw@1.3.3: {} + lodash.merge@4.6.2: {} - sade@1.8.1: - dependencies: - mri: 1.2.0 + lru-cache@11.2.6: {} - safe-array-concat@1.1.3: + magic-string@0.30.21: dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 + '@jridgewell/sourcemap-codec': 1.5.5 - safe-buffer@5.2.1: {} + mini-svg-data-uri@1.4.4: {} - safe-push-apply@1.0.0: + minimatch@10.2.2: dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 + brace-expansion: 5.0.2 - safe-regex-test@1.1.0: + minimatch@3.1.2: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - safer-buffer@2.1.2: {} + brace-expansion: 1.1.12 - saxes@6.0.0: + minimatch@9.0.5: dependencies: - xmlchars: 2.2.0 - - scheduler@0.27.0: {} - - semver@6.3.1: {} + brace-expansion: 2.0.2 - semver@7.7.3: {} + minipass@7.1.3: {} - serialize-error@8.1.0: + minizlib@3.1.0: dependencies: - type-fest: 0.20.2 + minipass: 7.1.3 - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 + mri@1.2.0: {} - set-cookie-parser@2.7.1: {} + mrmime@2.0.1: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 + ms@2.1.3: {} - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 + nanoid@3.3.11: {} - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 + natural-compare@1.4.0: {} - sha.js@2.4.12: + node-fetch@2.7.0: dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.2 + whatwg-url: 5.0.0 - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 + node-gyp-build@4.8.4: {} - shebang-regex@3.0.0: {} + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 - short-unique-id@5.3.2: {} + obug@2.1.1: {} - side-channel-list@1.0.0: + optionator@0.9.4: dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 - side-channel-map@1.0.1: + p-limit@3.1.0: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 + yocto-queue: 0.1.0 - side-channel-weakmap@1.0.2: + p-locate@5.0.0: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 + p-limit: 3.1.0 - side-channel@1.1.0: + parent-module@1.0.1: dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} + callsites: 3.1.0 - simple-concat@1.0.1: {} + path-exists@4.0.0: {} - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 + path-key@3.1.1: {} - sirv@3.0.2: + path-scurry@2.0.2: dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 + lru-cache: 11.2.6 + minipass: 7.1.3 - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 + pathe@2.0.3: {} - smob@1.5.0: {} + picocolors@1.1.1: {} - source-map-js@1.2.1: {} + picomatch@4.0.3: {} - source-map-support@0.5.21: + pixelmatch@7.1.0: dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 + pngjs: 7.0.0 - source-map@0.6.1: {} + playwright-core@1.58.2: {} - source-map@0.8.0-beta.0: + playwright@1.58.2: dependencies: - whatwg-url: 7.1.0 - - sourcemap-codec@1.4.8: {} - - space-separated-tokens@1.1.5: {} - - sprintf-js@1.0.3: {} - - stackback@0.0.2: {} + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 - stackblur-canvas@2.7.0: - optional: true + pngjs@7.0.0: {} - std-env@3.9.0: {} + postcss-load-config@3.1.4(postcss@8.5.6): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 - stop-iteration-iterator@1.1.0: + postcss-safe-parser@7.0.1(postcss@8.5.6): dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 + postcss: 8.5.6 - string-argv@0.3.2: {} + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 - string-width@4.2.3: + postcss-selector-parser@6.0.10: dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 + cssesc: 3.0.0 + util-deprecate: 1.0.2 - string-width@5.1.2: + postcss-selector-parser@7.1.1: dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + cssesc: 3.0.0 + util-deprecate: 1.0.2 - string-width@7.2.0: + postcss@8.5.6: dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 - string-width@8.1.0: - dependencies: - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + prelude-ls@1.2.1: {} - string.prototype.matchall@4.0.12: + prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0): dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 + prettier: 3.8.1 + svelte: 5.53.0 - string.prototype.trim@1.2.10: + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0))(prettier@3.8.1): dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 + prettier: 3.8.1 + optionalDependencies: + prettier-plugin-svelte: 3.5.0(prettier@3.8.1)(svelte@5.53.0) - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + prettier@3.8.1: {} - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + punycode@2.3.1: {} - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 + readdirp@4.1.2: {} - stringify-object@3.3.0: - dependencies: - get-own-enumerable-property-symbols: 3.0.2 - is-obj: 1.0.1 - is-regexp: 1.0.0 + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} - strip-ansi@6.0.1: + rollup@4.57.1: dependencies: - ansi-regex: 5.0.1 + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 - strip-ansi@7.1.2: + sade@1.8.1: dependencies: - ansi-regex: 6.2.2 + mri: 1.2.0 - strip-comments@2.0.1: {} + semver@7.7.4: {} - strip-indent@3.0.0: + set-cookie-parser@3.0.1: {} + + shebang-command@2.0.0: dependencies: - min-indent: 1.0.1 + shebang-regex: 3.0.0 - strip-json-comments@2.0.1: {} + shebang-regex@3.0.0: {} - strip-json-comments@3.1.1: {} + siginfo@2.0.0: {} - strip-literal@3.1.0: + sirv@3.0.2: dependencies: - js-tokens: 9.0.1 + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 - stripe@18.5.0(@types/node@24.7.2): - dependencies: - qs: 6.14.0 - optionalDependencies: - '@types/node': 24.7.2 + source-map-js@1.2.1: {} - style-to-object@1.0.11: + sqlite-wasm-kysely@0.3.0(kysely@0.27.6): dependencies: - inline-style-parser: 0.2.4 + '@sqlite.org/sqlite-wasm': 3.48.0-build4 + kysely: 0.27.6 - supabase@2.51.0: - dependencies: - bin-links: 5.0.0 - https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - tar: 7.5.1 - transitivePeerDependencies: - - supports-color + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - - svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.39.11)(typescript@5.9.3): + svelte-check@4.4.1(picomatch@4.0.3)(svelte@5.53.0)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.39.11 + svelte: 5.53.0 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.3.3(svelte@5.39.11): + svelte-eslint-parser@1.4.1(svelte@5.53.0): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 postcss: 8.5.6 postcss-scss: 4.0.9(postcss@8.5.6) - postcss-selector-parser: 7.1.0 + postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.39.11 + svelte: 5.53.0 - svelte-i18n@4.0.1(svelte@5.39.11): - dependencies: - cli-color: 2.0.4 - deepmerge: 4.3.1 - esbuild: 0.19.12 - estree-walker: 2.0.2 - intl-messageformat: 10.7.18 - sade: 1.8.1 - svelte: 5.39.11 - tiny-glob: 0.2.9 - - svelte-toolbelt@0.7.1(svelte@5.39.11): - dependencies: - clsx: 2.1.1 - runed: 0.23.4(svelte@5.39.11) - style-to-object: 1.0.11 - svelte: 5.39.11 - - svelte@5.39.11: + svelte@5.53.0: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@types/estree': 1.0.8 - acorn: 8.15.0 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 + devalue: 5.6.3 esm-env: 1.2.2 - esrap: 2.1.0 + esrap: 2.2.3 is-reference: 3.0.3 locate-character: 3.0.0 - magic-string: 0.30.19 + magic-string: 0.30.21 zimmerframe: 1.1.4 - svg-pathdata@6.0.3: - optional: true - - swagger-client@3.35.7: - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@scarf/scarf': 1.4.0 - '@swagger-api/apidom-core': 1.0.0-beta.50 - '@swagger-api/apidom-error': 1.0.0-beta.50 - '@swagger-api/apidom-json-pointer': 1.0.0-beta.50 - '@swagger-api/apidom-ns-openapi-3-1': 1.0.0-beta.50 - '@swagger-api/apidom-reference': 1.0.0-beta.50 - '@swaggerexpert/cookie': 2.0.2 - deepmerge: 4.3.1 - fast-json-patch: 3.1.1 - js-yaml: 4.1.0 - neotraverse: 0.6.18 - node-abort-controller: 3.1.1 - node-fetch-commonjs: 3.3.2 - openapi-path-templating: 2.2.1 - openapi-server-url-templating: 1.3.0 - ramda: 0.30.1 - ramda-adjunct: 5.1.0(ramda@0.30.1) - transitivePeerDependencies: - - debug - - swagger-ui@5.29.4: - dependencies: - '@babel/runtime-corejs3': 7.28.4 - '@scarf/scarf': 1.4.0 - base64-js: 1.5.1 - buffer: 6.0.3 - classnames: 2.5.1 - css.escape: 1.5.1 - deep-extend: 0.6.0 - dompurify: 3.2.6 - ieee754: 1.2.1 - immutable: 3.8.2 - js-file-download: 0.4.12 - js-yaml: 4.1.0 - lodash: 4.17.21 - prop-types: 15.8.1 - randexp: 0.5.3 - randombytes: 2.1.0 - react: 19.2.0 - react-copy-to-clipboard: 5.1.0(react@19.2.0) - react-debounce-input: 3.3.0(react@19.2.0) - react-dom: 19.2.0(react@19.2.0) - react-immutable-proptypes: 2.2.0(immutable@3.8.2) - react-immutable-pure-component: 2.2.2(immutable@3.8.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-inspector: 6.0.2(react@19.2.0) - react-redux: 9.2.0(react@19.2.0)(redux@5.0.1) - react-syntax-highlighter: 15.6.6(react@19.2.0) - redux: 5.0.1 - redux-immutable: 4.0.0(immutable@3.8.2) - remarkable: 2.0.1 - reselect: 5.1.1 - serialize-error: 8.1.0 - sha.js: 2.4.12 - swagger-client: 3.35.7 - url-parse: 1.5.10 - xml: 1.0.1 - xml-but-prettier: 1.0.1 - zenscroll: 4.0.2 - transitivePeerDependencies: - - '@types/react' - - debug - - symbol-tree@3.2.4: {} - - tabbable@6.2.0: {} - - tailwindcss@4.1.14: {} + tailwindcss@4.2.0: {} tapable@2.3.0: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar@7.5.1: + tar@7.5.9: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 - temp-dir@2.0.0: {} - - tempy@0.6.0: - dependencies: - is-stream: 2.0.1 - temp-dir: 2.0.0 - type-fest: 0.16.0 - unique-string: 2.0.0 - - terser@5.44.0: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 - commander: 2.20.3 - source-map-support: 0.5.21 - - text-segmentation@1.0.3: - dependencies: - utrie: 1.0.2 - optional: true - - timers-ext@0.1.8: - dependencies: - es5-ext: 0.10.64 - next-tick: 1.1.0 - - tiny-glob@0.2.9: - dependencies: - globalyzer: 0.1.0 - globrex: 0.1.2 - - tiny-inflate@1.0.3: {} - tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} - - tldts-core@6.1.86: {} - - tldts@6.1.86: - dependencies: - tldts-core: 6.1.86 - - to-buffer@1.2.2: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toggle-selection@1.0.6: {} + tinyrainbow@3.0.3: {} totalist@3.0.1: {} - tough-cookie@5.1.2: - dependencies: - tldts: 6.1.86 - tr46@0.0.3: {} - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - - tree-sitter-json@0.24.8(tree-sitter@0.21.1): - dependencies: - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - optionalDependencies: - tree-sitter: 0.21.1 - optional: true - - tree-sitter@0.21.1: - dependencies: - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - optional: true - - tree-sitter@0.22.4: - dependencies: - node-addon-api: 8.5.0 - node-gyp-build: 4.8.4 - optional: true - - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 - ts-mixer@6.0.4: {} - - ts-toolbelt@9.6.0: {} - tslib@2.8.1: {} - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-fest@0.16.0: {} - - type-fest@0.20.2: {} - - type@2.7.3: {} - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - types-ramda@0.30.1: - dependencies: - ts-toolbelt: 9.6.0 - - typescript-eslint@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@7.14.0: {} - - unicode-canonical-property-names-ecmascript@2.0.1: {} - - unicode-match-property-ecmascript@2.0.0: - dependencies: - unicode-canonical-property-names-ecmascript: 2.0.1 - unicode-property-aliases-ecmascript: 2.2.0 - - unicode-match-property-value-ecmascript@2.2.1: {} - - unicode-properties@1.4.1: - dependencies: - base64-js: 1.5.1 - unicode-trie: 2.0.0 - - unicode-property-aliases-ecmascript@2.2.0: {} - - unicode-trie@2.0.0: - dependencies: - pako: 0.2.9 - tiny-inflate: 1.0.3 - - unique-string@2.0.0: - dependencies: - crypto-random-string: 2.0.0 - - universalify@2.0.1: {} - - unraw@3.0.0: {} + undici-types@6.21.0: {} - upath@1.2.0: {} - - update-browserslist-db@1.1.3(browserslist@4.26.3): + unplugin@2.3.11: dependencies: - browserslist: 4.26.3 - escalade: 3.2.0 - picocolors: 1.1.1 + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 uri-js@4.4.1: dependencies: punycode: 2.3.1 - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - - use-sync-external-store@1.6.0(react@19.2.0): - dependencies: - react: 19.2.0 - - utf-8-validate@6.0.5: - dependencies: - node-gyp-build: 4.8.4 + urlpattern-polyfill@10.1.0: {} util-deprecate@1.0.2: {} - utrie@1.0.2: - dependencies: - base64-arraybuffer: 1.0.2 - optional: true - - vite-node@3.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + uuid@10.0.0: {} - vite-plugin-commonjs@0.10.4: - dependencies: - acorn: 8.15.0 - magic-string: 0.30.19 - vite-plugin-dynamic-import: 1.6.0 + uuid@11.1.0: {} - vite-plugin-dynamic-import@1.6.0: - dependencies: - acorn: 8.15.0 - es-module-lexer: 1.7.0 - fast-glob: 3.3.3 - magic-string: 0.30.19 + uuid@13.0.0: {} - vite-plugin-pwa@1.0.3(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(workbox-build@7.3.0)(workbox-window@7.3.0): + vite-plugin-devtools-json@1.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)): dependencies: - debug: 4.4.3 - pretty-bytes: 6.1.1 - tinyglobby: 0.2.15 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - workbox-build: 7.3.0 - workbox-window: 7.3.0 - transitivePeerDependencies: - - supports-color + uuid: 11.1.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) - vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1): + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: - esbuild: 0.25.10 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.52.4 + rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.7.2 + '@types/node': 22.19.11 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.1 - terser: 5.44.0 - yaml: 2.8.1 + lightningcss: 1.31.1 - vitefu@1.1.1(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)): optionalDependencies: - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - - vitest@3.2.4(@types/node@24.7.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1): - dependencies: - '@types/chai': 5.2.2 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.19 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) + + vitest-browser-svelte@2.0.2(svelte@5.53.0)(vitest@4.0.18): + dependencies: + '@testing-library/svelte-core': 1.0.0(svelte@5.53.0) + svelte: 5.53.0 + vitest: 4.0.18(@types/node@22.19.11)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1) + + vitest@4.0.18(@types/node@22.19.11)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.9.0 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 6.3.6(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.7.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.7.2 - jsdom: 26.1.0(bufferutil@4.0.9)(canvas@3.2.0)(utf-8-validate@6.0.5) + '@types/node': 22.19.11 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18) transitivePeerDependencies: - jiti - less @@ -10201,89 +3726,19 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser - tsx - yaml - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - web-streams-polyfill@3.3.3: {} - - web-tree-sitter@0.24.5: - optional: true - webidl-conversions@3.0.1: {} - webidl-conversions@4.0.2: {} - - webidl-conversions@7.0.0: {} - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 + webpack-virtual-modules@0.6.2: {} whatwg-url@5.0.0: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.19 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -10295,171 +3750,12 @@ snapshots: word-wrap@1.2.5: {} - workbox-background-sync@7.3.0: - dependencies: - idb: 7.1.1 - workbox-core: 7.3.0 - - workbox-broadcast-update@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-build@7.3.0: - dependencies: - '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) - '@babel/core': 7.28.4 - '@babel/preset-env': 7.28.3(@babel/core@7.28.4) - '@babel/runtime': 7.28.4 - '@rollup/plugin-babel': 5.3.1(@babel/core@7.28.4)(rollup@2.79.2) - '@rollup/plugin-node-resolve': 15.3.1(rollup@2.79.2) - '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) - '@rollup/plugin-terser': 0.4.4(rollup@2.79.2) - '@surma/rollup-plugin-off-main-thread': 2.2.3 - ajv: 8.17.1 - common-tags: 1.8.2 - fast-json-stable-stringify: 2.1.0 - fs-extra: 9.1.0 - glob: 7.2.3 - lodash: 4.17.21 - pretty-bytes: 5.6.0 - rollup: 2.79.2 - source-map: 0.8.0-beta.0 - stringify-object: 3.3.0 - strip-comments: 2.0.1 - tempy: 0.6.0 - upath: 1.2.0 - workbox-background-sync: 7.3.0 - workbox-broadcast-update: 7.3.0 - workbox-cacheable-response: 7.3.0 - workbox-core: 7.3.0 - workbox-expiration: 7.3.0 - workbox-google-analytics: 7.3.0 - workbox-navigation-preload: 7.3.0 - workbox-precaching: 7.3.0 - workbox-range-requests: 7.3.0 - workbox-recipes: 7.3.0 - workbox-routing: 7.3.0 - workbox-strategies: 7.3.0 - workbox-streams: 7.3.0 - workbox-sw: 7.3.0 - workbox-window: 7.3.0 - transitivePeerDependencies: - - '@types/babel__core' - - supports-color - - workbox-cacheable-response@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-core@7.3.0: {} - - workbox-expiration@7.3.0: - dependencies: - idb: 7.1.1 - workbox-core: 7.3.0 - - workbox-google-analytics@7.3.0: - dependencies: - workbox-background-sync: 7.3.0 - workbox-core: 7.3.0 - workbox-routing: 7.3.0 - workbox-strategies: 7.3.0 - - workbox-navigation-preload@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-precaching@7.3.0: - dependencies: - workbox-core: 7.3.0 - workbox-routing: 7.3.0 - workbox-strategies: 7.3.0 - - workbox-range-requests@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-recipes@7.3.0: - dependencies: - workbox-cacheable-response: 7.3.0 - workbox-core: 7.3.0 - workbox-expiration: 7.3.0 - workbox-precaching: 7.3.0 - workbox-routing: 7.3.0 - workbox-strategies: 7.3.0 - - workbox-routing@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-strategies@7.3.0: - dependencies: - workbox-core: 7.3.0 - - workbox-streams@7.3.0: - dependencies: - workbox-core: 7.3.0 - workbox-routing: 7.3.0 - - workbox-sw@7.3.0: {} - - workbox-window@7.3.0: - dependencies: - '@types/trusted-types': 2.0.7 - workbox-core: 7.3.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - write-file-atomic@6.0.0: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - - ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5): - optionalDependencies: - bufferutil: 4.0.9 - utf-8-validate: 6.0.5 - - xml-but-prettier@1.0.1: - dependencies: - repeat-string: 1.6.1 - - xml-name-validator@5.0.0: {} - - xml@1.0.1: {} - - xmlchars@2.2.0: {} - - xtend@4.0.2: {} - - yallist@3.1.1: {} + ws@8.19.0: {} yallist@5.0.0: {} yaml@1.10.2: {} - yaml@2.8.1: {} - yocto-queue@0.1.0: {} - zenscroll@4.0.2: {} - zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..8ddd474f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - '.' + +onlyBuiltDependencies: + - esbuild diff --git a/project.inlang/settings.json b/project.inlang/settings.json new file mode 100644 index 00000000..ca048aff --- /dev/null +++ b/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "ja", + "locales": ["ja", "en"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/scripts/build-info.js b/scripts/build-info.js deleted file mode 100644 index 5adf1bea..00000000 --- a/scripts/build-info.js +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -import { execSync } from 'child_process'; -import os from 'os'; -import fs from 'fs'; -import path from 'path'; - -// helper to run shell commands -function run(cmd) { - return execSync(cmd, { encoding: 'utf8' }).trim(); -} - -// 1. Git info -const commitHash = run('git rev-parse --short HEAD'); -const branch = run('git rev-parse --abbrev-ref HEAD'); -const commitUser = run("git log -1 --pretty=format:'%an'"); - -// 2. Timestamp -const buildDate = new Date().toISOString(); - -// 3. Host info -const host = os.hostname(); -const user = os.userInfo().username; - -// 4. Local IP (first non-internal IPv4) -function getLocalIP() { - const nets = os.networkInterfaces(); - for (const name of Object.keys(nets)) { - for (const net of nets[name]) { - if (net.family === 'IPv4' && !net.internal) { - return net.address; - } - } - } - return '127.0.0.1'; -} -const ipAddress = getLocalIP(); - -// format output as JSON -const buildInfo = { - commit: commitHash, - branch: branch, - author: commitUser, - date: buildDate, - builder: `${user}@${host}`, - ipAddress: ipAddress, - timestamp: Date.now() -}; - -const jsonOutput = JSON.stringify(buildInfo, null, 2); - -// print to console -console.log('\n\x1b[33m=== Build Info ===\x1b[0m'); -console.log(` • Commit : ${buildInfo.commit}`); -console.log(` • Branch : ${buildInfo.branch}`); -console.log(` • Author : ${buildInfo.author}`); -console.log(` • Date : ${buildInfo.date}`); -console.log(` • Builder : ${buildInfo.builder}`); -console.log(` • IP Address : ${buildInfo.ipAddress}`); -console.log('\x1b[33m==================\x1b[0m\n'); - -// write to JSON file in static directory -const staticDir = path.join(process.cwd(), 'static'); -if (!fs.existsSync(staticDir)) { - fs.mkdirSync(staticDir, { recursive: true }); -} - -const filePath = path.join(staticDir, 'build-info.json'); -fs.writeFileSync(filePath, jsonOutput + '\n', { encoding: 'utf8' }); -console.log(`Build info written to ${filePath}\n`); diff --git a/scripts/download-notojp.sh b/scripts/download-notojp.sh deleted file mode 100755 index 7f02011a..00000000 --- a/scripts/download-notojp.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Download NotoSansJP-Regular.ttf into static/fonts -set -euo pipefail -OUT_DIR="$(pwd)/static/fonts" -URL="https://github.com/googlefonts/noto-cjk/raw/main/Sans/TTF/Japanese/NotoSansJP-Regular.otf" -# Note: the repository contains .otf; PDFKit supports OTF too, but the code expects .ttf filename. -OUT_FILE="$OUT_DIR/NotoSansJP-Regular.otf" -mkdir -p "$OUT_DIR" -if command -v curl >/dev/null 2>&1; then - curl -L -o "$OUT_FILE" "$URL" -elif command -v wget >/dev/null 2>&1; then - wget -O "$OUT_FILE" "$URL" -else - echo "Install curl or wget to run this script." >&2 - exit 1 -fi -# Inform the user -echo "Downloaded $OUT_FILE" - -echo "If you need a .ttf file instead of .otf, convert it using fonttools or obtain the .ttf distribution." diff --git a/scripts/e2e-stack.mjs b/scripts/e2e-stack.mjs new file mode 100644 index 00000000..5173ef23 --- /dev/null +++ b/scripts/e2e-stack.mjs @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import process from 'node:process'; + +const APP_PORT = 4173; +const MOCK_PORT = 4174; +const VALID_EMAIL = 'operator@cropwatch.test'; +const VALID_PASSWORD = 'CorrectHorseBatteryStaple1!'; + +function encodeSegment(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function createJwt(expirationOffsetSeconds = 60 * 60) { + return [ + encodeSegment({ alg: 'HS256', typ: 'JWT' }), + encodeSegment({ + sub: 'playwright-user', + email: VALID_EMAIL, + role: 'operator', + exp: Math.floor(Date.now() / 1000) + expirationOffsetSeconds, + user_metadata: { + full_name: 'Playwright Operator', + name: 'playwright-operator' + } + }), + 'test-signature' + ].join('.'); +} + +async function readJsonBody(request) { + const chunks = []; + + for await (const chunk of request) { + chunks.push(chunk); + } + + const rawBody = Buffer.concat(chunks).toString('utf8'); + return rawBody ? JSON.parse(rawBody) : {}; +} + +function writeJson(response, statusCode, payload) { + response.writeHead(statusCode, { + 'content-type': 'application/json' + }); + response.end(JSON.stringify(payload)); +} + +function createMockServer() { + return http.createServer(async (request, response) => { + const url = new URL(request.url ?? '/', `http://127.0.0.1:${MOCK_PORT}`); + + if (request.method === 'POST' && /^\/v1\/projects\/[^/]+\/assessments$/.test(url.pathname)) { + const body = await readJsonBody(request); + const token = String(body?.event?.token ?? ''); + const action = String(body?.event?.expectedAction ?? 'LOGIN'); + + if (!token || token.includes('invalid')) { + writeJson(response, 200, { + tokenProperties: { + valid: false, + invalidReason: 'MALFORMED' + } + }); + return; + } + + writeJson(response, 200, { + tokenProperties: { + valid: true, + action + }, + riskAnalysis: { + score: 0.99, + reasons: [] + } + }); + return; + } + + if (request.method === 'POST' && url.pathname === '/auth/login') { + const body = await readJsonBody(request); + const email = String(body?.email ?? '') + .trim() + .toLowerCase(); + const password = String(body?.password ?? ''); + + if (email !== VALID_EMAIL || password !== VALID_PASSWORD) { + writeJson(response, 401, { + message: 'Invalid credentials.' + }); + return; + } + + writeJson(response, 200, { + message: 'Login successful.', + result: { + accessToken: createJwt(), + expires_at_datetime: new Date(Date.now() + 60 * 60 * 1000).toISOString() + } + }); + return; + } + + if (request.method === 'GET' && url.pathname === '/auth/user-profile') { + if (!request.headers.authorization?.startsWith('Bearer ')) { + writeJson(response, 401, { message: 'Unauthorized' }); + return; + } + + writeJson(response, 200, { + employer: 'Playwright Farm' + }); + return; + } + + writeJson(response, 404, { + message: 'Not Found', + path: url.pathname + }); + }); +} + +function waitForExit(child) { + return new Promise((resolve, reject) => { + child.once('exit', (code) => resolve(code ?? 0)); + child.once('error', reject); + }); +} + +async function main() { + const mockServer = createMockServer(); + await new Promise((resolve, reject) => { + mockServer.once('error', reject); + mockServer.listen(MOCK_PORT, '127.0.0.1', resolve); + }); + + const build = spawn('npm', ['run', 'build'], { + stdio: 'inherit', + env: process.env + }); + + const buildExitCode = await waitForExit(build); + if (buildExitCode !== 0) { + await new Promise((resolve) => mockServer.close(resolve)); + process.exit(buildExitCode); + } + + const preview = spawn( + 'npm', + ['run', 'preview', '--', '--host', '127.0.0.1', '--port', String(APP_PORT)], + { + stdio: 'inherit', + env: { + ...process.env, + PUBLIC_API_BASE_URL: `http://127.0.0.1:${MOCK_PORT}`, + PUBLIC_RECAPTCHA_SITE_KEY: 'playwright-site-key', + PRIVATE_RECAPTCHA_API_BASE_URL: `http://127.0.0.1:${MOCK_PORT}`, + PRIVATE_RECAPTCHA_PROJECT_ID: 'playwright-project', + PRIVATE_RECAPTCHA_API_KEY: 'playwright-api-key', + PRIVATE_RECAPTCHA_SITE_KEY: 'playwright-site-key' + } + } + ); + + const shutdown = async (exitCode = 0) => { + if (!preview.killed) { + preview.kill('SIGTERM'); + } + + await new Promise((resolve) => mockServer.close(resolve)); + process.exit(exitCode); + }; + + process.on('SIGINT', () => void shutdown(130)); + process.on('SIGTERM', () => void shutdown(143)); + + preview.once('exit', async (code) => { + await new Promise((resolve) => mockServer.close(resolve)); + process.exit(code ?? 0); + }); +} + +void main().catch((error) => { + console.error('Failed to start the Playwright E2E stack:', error); + process.exit(1); +}); diff --git a/scripts/generate-sample-pdf.cjs b/scripts/generate-sample-pdf.cjs deleted file mode 100644 index 2268768a..00000000 --- a/scripts/generate-sample-pdf.cjs +++ /dev/null @@ -1,76 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const PDFDocument = require('pdfkit'); - -(async () => { - try { - const outPath = path.join(process.cwd(), 'static/test-sample-report.pdf'); - const possibleFontPaths = [ - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.otf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.otf') - ]; - - let fontRegistered = false; - let registeredFontPath = null; - for (const fp of possibleFontPaths) { - try { - if (fs.existsSync(fp)) { - registeredFontPath = fp; - fontRegistered = true; - break; - } - } catch (e) { - // ignore - } - } - - const doc = new PDFDocument({ size: 'A4', margin: 40, bufferPages: true }); - const stream = fs.createWriteStream(outPath); - doc.pipe(stream); - - if (fontRegistered) { - try { - doc.registerFont('NotoSansJP', registeredFontPath); - doc.font('NotoSansJP'); - } catch (e) { - console.warn('Failed to register font', registeredFontPath, e.message); - doc.font('Helvetica'); - } - } else { - console.warn('No NotoSansJP font found, falling back to Helvetica'); - doc.font('Helvetica'); - } - - console.log('Using font:', fontRegistered ? 'NotoSansJP -> ' + registeredFontPath : 'Helvetica'); - - const { top: marginTop, left: marginLeft } = doc.page.margins; - const headerTitle = 'CropWatch Device Report'; - const displayStartLabel = '2025-08-01 00:00'; - const displayEndLabel = '2025-08-01 23:59'; - const timezoneParam = 'Asia/Tokyo'; - - const headerX = marginLeft; - const headerY = marginTop - 4; - doc.fontSize(16).text(headerTitle, headerX, headerY, { align: 'left' }); - - const metaY = headerY + 20; - doc.fontSize(8).text(`Date range: ${displayStartLabel} - ${displayEndLabel} (${timezoneParam})`, headerX, metaY, { - align: 'left' - }); - - // add a Japanese sample line to visually verify font - doc.moveDown(2).fontSize(12).text('日本語のテキスト (sample Japanese text)'); - - doc.end(); - - stream.on('finish', () => { - const size = fs.statSync(outPath).size; - console.log('Wrote sample PDF to', outPath, 'size:', size); - }); - } catch (err) { - console.error('Error generating sample PDF', err); - process.exit(1); - } -})(); diff --git a/scripts/generate-sample-pdf.js b/scripts/generate-sample-pdf.js deleted file mode 100644 index 87fe8c08..00000000 --- a/scripts/generate-sample-pdf.js +++ /dev/null @@ -1,86 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const PDFDocument = require('pdfkit'); - -(async () => { - try { - const outPath = path.join(process.cwd(), 'static/test-sample-report.pdf'); - const possibleFontPaths = [ - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.otf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.otf') - ]; - - let fontRegistered = false; - let registeredFontPath = null; - for (const fp of possibleFontPaths) { - try { - if (fs.existsSync(fp)) { - registeredFontPath = fp; - fontRegistered = true; - break; - } - } catch (e) { - // ignore - } - } - - const doc = new PDFDocument({ size: 'A4', margin: 40, bufferPages: true }); - const stream = fs.createWriteStream(outPath); - doc.pipe(stream); - - if (fontRegistered) { - try { - doc.registerFont('NotoSansJP', registeredFontPath); - doc.font('NotoSansJP'); - } catch (e) { - console.warn('Failed to register font', registeredFontPath, e.message); - doc.font('Helvetica'); - } - } else { - console.warn('No NotoSansJP font found, falling back to Helvetica'); - doc.font('Helvetica'); - } - - console.log( - 'Using font:', - fontRegistered ? 'NotoSansJP -> ' + registeredFontPath : 'Helvetica' - ); - - const { top: marginTop, left: marginLeft } = doc.page.margins; - const headerTitle = 'CropWatch Device Report'; - const displayStartLabel = '2025-08-01 00:00'; - const displayEndLabel = '2025-08-01 23:59'; - const timezoneParam = 'Asia/Tokyo'; - - const headerX = marginLeft; - const headerY = marginTop - 4; - doc.fontSize(16).text(headerTitle, headerX, headerY, { align: 'left' }); - - const metaY = headerY + 20; - doc - .fontSize(8) - .text( - `Date range: ${displayStartLabel} - ${displayEndLabel} (${timezoneParam})`, - headerX, - metaY, - { - align: 'left' - } - ); - - // add a Japanese sample line to visually verify font - doc.moveDown(2).fontSize(12).text('日本語のテキスト (sample Japanese text)'); - - doc.end(); - - stream.on('finish', () => { - const size = fs.statSync(outPath).size; - console.log('Wrote sample PDF to', outPath, 'size:', size); - }); - } catch (err) { - console.error('Error generating sample PDF', err); - process.exit(1); - } -})(); diff --git a/scripts/style-audit.mjs b/scripts/style-audit.mjs new file mode 100644 index 00000000..a5ec4bcc --- /dev/null +++ b/scripts/style-audit.mjs @@ -0,0 +1,98 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; + +const rootDir = process.cwd(); +const sourceDir = join(rootDir, 'src'); +const allowedExtensions = new Set(['.svelte', '.css', '.ts', '.js']); +const allowedBreakpoints = new Set(['639', '640', '767', '768', '1023', '1024']); +const legacyAllowlist = [ + /^src\/lib\/components\/dashboard\//, + /^src\/lib\/components\/displays\//, + /^src\/lib\/components\/status\//, + /^src\/lib\/pwa\/manifest\.ts$/, + /^src\/routes\/auth\//, + /^src\/routes\/demo\//, + /^src\/routes\/locations\//, + /^src\/routes\/offline\// +]; + +const findings = []; + +function isAllowlisted(filePath) { + return legacyAllowlist.some((pattern) => pattern.test(filePath)); +} + +function collectFiles(directory) { + const files = []; + + for (const entry of readdirSync(directory)) { + const absolutePath = join(directory, entry); + const stats = statSync(absolutePath); + + if (stats.isDirectory()) { + files.push(...collectFiles(absolutePath)); + continue; + } + + if (allowedExtensions.has(extname(absolutePath))) { + files.push(absolutePath); + } + } + + return files; +} + +function addFinding(filePath, lineNumber, rule, excerpt) { + findings.push({ + filePath, + lineNumber, + rule, + excerpt: excerpt.trim() + }); +} + +for (const absolutePath of collectFiles(sourceDir)) { + const filePath = relative(rootDir, absolutePath).replaceAll('\\', '/'); + + if (isAllowlisted(filePath)) { + continue; + } + + const lines = readFileSync(absolutePath, 'utf8').split(/\r?\n/); + + lines.forEach((line, index) => { + const lineNumber = index + 1; + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return; + } + + if (!trimmed.includes('theme-color') && /#[0-9a-fA-F]{3,8}\b/.test(trimmed)) { + addFinding(filePath, lineNumber, 'hard-coded hex color', line); + } + + if (/\brgba?\(/.test(trimmed)) { + addFinding(filePath, lineNumber, 'hard-coded rgb color', line); + } + + for (const match of trimmed.matchAll(/@media\s*\((?:max|min)-width:\s*(\d+)px\)/g)) { + const breakpoint = match[1]; + if (!allowedBreakpoints.has(breakpoint)) { + addFinding(filePath, lineNumber, `non-standard breakpoint ${breakpoint}px`, line); + } + } + }); +} + +if (findings.length > 0) { + console.error('CWUI style audit failed:\n'); + for (const finding of findings) { + console.error( + `- ${finding.filePath}:${finding.lineNumber} ${finding.rule}\n ${finding.excerpt}` + ); + } + process.exit(1); +} + +console.log('CWUI style audit passed.'); diff --git a/src/app.css b/src/app.css deleted file mode 100644 index 8d239c30..00000000 --- a/src/app.css +++ /dev/null @@ -1,782 +0,0 @@ -/* @import 'tailwindcss'; -@plugin '@tailwindcss/forms'; -@plugin '@tailwindcss/typography'; */ - -@import 'tailwindcss'; - -/* Removed @layerstack/tailwind and svelte-ux source imports; using local styles only */ -/* Tailwind typography plugin configured via eslint.config.js / tailwind config; inline @plugin removed */ - -/* Define color variables for light mode */ - -/* Design tokens (base) */ -:root { - /* Base colors */ - --color-primary: #4caf50; - --color-primary-hover: #3a8c3f; - --color-background: #eaeaea; - --color-foreground: #938aee; - --color-foreground-light: #f5f5f5; - --color-foreground-dark: #2a2a2a; - --color-card: #ffffff; - --color-text: #1f2937; - --color-text-secondary: rgba(31, 41, 55, 0.7); - - /* Specialized colors */ - --color-air-bg: #e6f7ff; - --color-air-border: #1e88e5; - --color-soil-bg: #f6ffed; - --color-soil-border: #4caf50; - - /* Utility colors */ - --color-error: #e53935; - --color-error-bg: #ffebee; - --color-success: #43a047; - --color-success-bg: #e8f5e9; - - --color-temperature: #ff6b6b; - --color-humidity: #4ecdc4; - --color-moisture: #45b7d8; - --color-co2: #a98248; - --color-ph: #6a0572; - --color-pressure: #505055; - --color-battery: #bdca01; - - --color-hotpink-400: #f472b6; - --color-silver-400: #c0c0c0; - - /* Shadow */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - - /* Standard UI colors */ - --color-border: rgb(219 224 231); - --color-border-subtle: rgb(229 231 235); - --color-surface: rgb(249 250 251); - --color-surface-raised: rgb(255 255 255); - --color-text-muted: rgb(75 85 99); - --color-surface-muted: rgba(255, 255, 255, 0.85); - --color-surface-emphasis: color-mix(in srgb, var(--color-foreground) 6%, #ffffff 94%); - --color-header: color-mix(in srgb, var(--color-background) 80%, rgba(255, 255, 255, 0.8) 20%); - --color-sidebar: color-mix(in srgb, var(--color-background) 75%, rgba(255, 255, 255, 0.9) 25%); -} - -/* Dark mode color variables (activated by .dark class on ) */ -.dark { - /* Base colors */ - --color-primary: #3a8c3f; - --color-primary-hover: #4caf50; - --color-background: #1a1a1a; - --color-foreground: #2d2d2d; - --color-foreground-light: #383838; - --color-foreground-dark: #1f1f1f; - --color-card: #2d2d2d; - --color-text: #e0e0e0; - --color-text-secondary: rgba(224, 224, 224, 0.7); - - /* Specialized colors */ - --color-air-bg: #0d3b66; - --color-air-border: #0277bd; - --color-soil-bg: #1e4620; - --color-soil-border: #2e7d32; - - /* Utility colors */ - --color-error: #ef5350; - --color-error-bg: rgba(229, 57, 53, 0.2); - --color-success: #66bb6a; - --color-success-bg: rgba(67, 160, 71, 0.2); - - /* Shadow */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); - - /* Standard UI colors */ - --color-border: rgb(75 85 99); - --color-border-subtle: rgb(75 85 99 / 0.3); - --color-surface: rgb(55 65 81); - --color-surface-raised: rgb(55 65 81); - --color-text-muted: rgb(156 163 175); - --color-surface-muted: rgba(31, 41, 55, 0.78); - --color-surface-emphasis: color-mix(in srgb, var(--color-foreground-light) 60%, rgba(15, 23, 42, 0.6) 40%); - --color-header: color-mix(in srgb, var(--color-background) 88%, rgba(15, 23, 42, 0.9) 12%); - --color-sidebar: color-mix(in srgb, var(--color-background) 82%, rgba(30, 41, 59, 0.92) 18%); -} - -html, -body { - scroll-behavior: smooth; - height: 100%; - background-color: var(--color-background); - color: var(--color-text); - transition: - background-color 0.3s ease, - color 0.3s ease; -} - -.grow { - flex-grow: 1; -} - -body { - background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 92%, #ffffff 8%) 0%, var(--color-background) 45%, var(--color-background) 100%); - color: var(--color-text); -} - -.dark body { - background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 85%, #0f172a 15%) 0%, var(--color-background) 45%, var(--color-background) 100%); -} - -/* Global card styles */ -.card { - background-color: var(--color-card); - color: var(--color-text); - border-radius: 0.375rem; - box-shadow: var(--shadow-sm); - transition: - background-color 0.3s ease, - color 0.3s ease, - box-shadow 0.3s ease; -} -.card:hover { - box-shadow: var(--shadow-md); -} - -/* Button styles */ -button.primary { background-color: var(--color-primary); color: white; transition: background-color .3s ease; } -button.primary:hover { background-color: var(--color-primary-hover); } - -/* Unified button token mapping */ -:root { - --btn-radius: 0.375rem; - --btn-font-weight: 600; - --btn-padding-y: 0.5rem; - --btn-padding-x: 1rem; - --btn-shadow: 0 1px 2px rgba(0 0 0 / 0.08); - --btn-primary-bg: var(--color-primary); - --btn-primary-bg-hover: var(--color-primary-hover); - --btn-primary-text: #ffffff; - --btn-secondary-bg: var(--color-foreground-light); - --btn-secondary-bg-hover: #e2e2e2; - --btn-secondary-text: var(--color-text); -} -.dark { - --btn-secondary-bg: var(--color-foreground-light); - --btn-secondary-bg-hover: #444444; - --btn-secondary-text: var(--color-text); -} - -/* Panel tokens */ -:root { - --panel-bg-light: var(--color-foreground-light); - --panel-bg-dark: var(--color-foreground-dark); -} - -[data-theme='light'] .panel { background: var(--panel-bg-light); color: var(--color-text); } -[data-theme='dark'] .panel { background: var(--panel-bg-dark); color: var(--color-text); } -.dark .panel { background: var(--panel-bg-dark); } - - -.btn-base { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--btn-radius); - font-weight: var(--btn-font-weight); - padding: var(--btn-padding-y) var(--btn-padding-x); - box-shadow: var(--btn-shadow); - transition: - background-color 180ms ease, - color 180ms ease, - border-color 180ms ease, - box-shadow 180ms ease, - transform 120ms ease; - outline: none; - line-height: 1.3; - border: 1px solid transparent; -} -.btn-base:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} -.btn-base:active { - transform: translateY(1px); -} -.btn-base:disabled { - opacity: 0.6; - cursor: not-allowed; - box-shadow: none; -} -.btn-primary { - background: var(--btn-primary-bg); - color: var(--btn-primary-text); -} -.btn-primary:hover { - background: var(--btn-primary-bg-hover); -} -.btn-secondary { - background: var(--btn-secondary-bg); - color: var(--btn-secondary-text); -} -.btn-secondary:hover { - background: var(--btn-secondary-bg-hover); -} -.btn-ghost { - background: transparent; - color: inherit; -} -.btn-ghost:hover { - background-color: rgba(0 0 0 / 0.05); -} -.dark .btn-ghost:hover { - background-color: rgba(255 255 255 / 0.08); -} -.btn-outline { - background: transparent; - border: 1px solid var(--btn-secondary-bg-hover); - color: var(--btn-secondary-text); -} -.btn-outline:hover { - background-color: rgba(0 0 0 / 0.05); -} -.dark .btn-outline:hover { - background-color: rgba(255 255 255 / 0.08); -} - - -/* Sensor data specific styles */ -.air-data { - background-color: var(--color-air-bg); - border-left: 4px solid var(--color-air-border); -} - -.soil-data { - background-color: var(--color-soil-bg); - border-left: 4px solid var(--color-soil-border); -} - -/* Utility classes */ -.text-secondary { - color: var(--color-text-secondary); -} - -.surface-card { - background-color: color-mix(in srgb, var(--color-card) 96%, #ffffff 4%); - color: var(--color-text); - border-radius: 0.9rem; - border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); - box-shadow: - 0 26px 38px -32px rgba(15 23 42 / 0.55), - 0 12px 28px -36px rgba(15 23 42 / 0.45), - 0 1px 3px rgba(15 23 42 / 0.12); - transition: - background-color 180ms ease, - border-color 180ms ease, - box-shadow 200ms ease; -} - -.surface-section { - background-color: color-mix(in srgb, var(--color-surface) 96%, #ffffff 4%); - border-radius: 0.9rem; - border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); -} - -.surface-muted { - background-color: color-mix(in srgb, var(--color-surface-emphasis) 95%, #ffffff 5%); - border-radius: 0.9rem; -} - -.header-surface { - background-color: var(--color-header); - backdrop-filter: blur(14px); - border-bottom: 1px solid var(--color-border-subtle); - box-shadow: 0 8px 24px -20px rgba(15, 23, 42, 0.4); -} - -.sidebar-surface { - background-color: var(--color-sidebar); - backdrop-filter: blur(18px); - border-right: 1px solid var(--color-border-subtle); - box-shadow: 12px 0 28px -28px rgba(15, 23, 42, 0.45); -} - -.dark .surface-card { - background-color: color-mix(in srgb, var(--color-foreground-dark) 92%, rgba(15, 23, 42, 0.85) 8%); - border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); -} - -.dark .surface-section { - background-color: color-mix(in srgb, var(--color-foreground-dark) 88%, rgba(15, 23, 42, 0.8) 12%); - border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); -} - -.dark .surface-muted { - background-color: color-mix(in srgb, var(--color-foreground-dark) 75%, rgba(30, 41, 59, 0.9) 25%); -} - -.dark .header-surface, -.dark .sidebar-surface { - border-color: var(--color-border-subtle); -} - -.error-message { - color: var(--color-error); - background-color: var(--color-error-bg); - border-left: 4px solid var(--color-error); - padding: 0.625rem; - margin: 0.625rem 0; - border-radius: 0.25rem; -} - -/* Smooth transitions for light/dark mode */ -*, -*::before, -*::after { - transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow; - transition-duration: 200ms; -} - -.bg-foreground-light { - background-color: var(--color-foreground-light); -} -.bg-foreground-dark { - background-color: var(--color-foreground-dark); -} - -/* Standard surface patterns for consistent theming */ -.dropdown-surface { - border-radius: 0.375rem; - border: 1px solid var(--color-border); - background-color: var(--color-surface); - box-shadow: var(--shadow-md); -} - -.card-surface { - border-radius: 0.75rem; - border: 1px solid var(--color-border); - background-color: var(--color-surface); - box-shadow: var(--shadow-md); -} - -.panel-surface { - border-radius: 0.5rem; - border: 1px solid var(--color-border); - background-color: var(--color-surface-raised); - box-shadow: var(--shadow-sm); -} - -.menu-item { - display: flex; - cursor: pointer; - align-items: center; - border-radius: 0.375rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - line-height: 1.25rem; - color: var(--color-text); - transition: all 150ms; -} -.menu-item:hover { - background-color: var(--color-surface); - box-shadow: var(--shadow-sm); -} - -.menu-item.active { - background-color: var(--color-surface); - font-weight: 500; -} - -.divider { - height: 1px; - background-color: var(--color-border); -} - -.divider-vertical { - width: 1px; - background-color: var(--color-border); -} - -/* Standardized utility classes using design tokens */ -.border-standard { - border-color: var(--color-border); -} - -.border-subtle { - border-color: var(--color-border-subtle); -} - -.surface { - background-color: var(--color-surface); -} - -.surface-raised { - background-color: var(--color-surface-raised); -} - -.text-muted { - color: var(--color-text-muted); -} - -/* Explicit fallback utility classes for card backgrounds */ -.bg-card-light { - background-color: var(--color-card, #ffffff); -} -.dark .bg-card-dark { - background-color: var(--color-card, #2d2d2d); -} - -/* Buttons */ -button { - cursor: pointer; -} - -button:disabled { - cursor: not-allowed; -} - -/* links */ -a { - text-decoration: underline; -} - -/* Uniform form styles */ -.form-container { - display: flex; - flex-direction: column; - gap: 1.25rem; - background-color: var(--color-card); - padding: 1.75rem; - border-radius: 0.75rem; - border: 1px solid var(--color-border-subtle); - box-shadow: var(--shadow-md); - transition: box-shadow 180ms ease; -} -.form-container:hover { - box-shadow: - 0 14px 30px -20px rgb(15 23 42 / 0.35), - 0 8px 18px -12px rgb(15 23 42 / 0.2); -} -.form-container label { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text); -} - - -/* override button for event calendar */ -.ec-dayGridMonth, .ec-today { - display: none !important; -} - -/* in your global CSS (e.g. app.css), *after* the Tailwind layers */ -input[type="date"]::-webkit-calendar-picker-indicator { - /* position relative to the */ - position: absolute; - top: 50%; - right: 0.5rem; /* tweak to exactly your taste */ - transform: translateY(-50%); - /* keep it clickable: */ - pointer-events: all; -} - -/* if you need Firefox support (Gecko), you can also add: */ -input[type="date"]::-moz-calendar-picker-indicator { - position: absolute; - top: 50%; - right: 0.5rem; - transform: translateY(-50%); -} - -/* Themed date input surface contrast - Light mode: slightly darker than page background - Dark mode: slightly lighter than page background - Uses existing CSS variables so tokens stay centralized. -*/ -input[type="date"], -input[type="datetime-local"], -input[type="time"], -input[type="month"], -input[type="week"] { - background-color: color-mix(in oklab, var(--color-background) 92%, black); /* subtle darkening for light mode */ - color: var(--color-text); - border: 1px solid color-mix(in oklab, var(--color-background) 75%, black); - transition: background-color .25s, border-color .25s; -} - -.dark input[type="date"], -.dark input[type="datetime-local"], -.dark input[type="time"], -.dark input[type="month"], -.dark input[type="week"] { - /* lighten relative to dark background */ - background-color: color-mix(in oklab, var(--color-background) 85%, white); - border-color: color-mix(in oklab, var(--color-background) 65%, white); -} - -/* Forced light while system dark: ensure we use light-mode rendition (force-light removes .dark, but guard anyway) */ -:root.force-light input[type="date"], -:root.force-light input[type="datetime-local"], -:root.force-light input[type="time"], -:root.force-light input[type="month"], -:root.force-light input[type="week"] { - background-color: color-mix(in oklab, var(--color-background) 92%, black) !important; - border-color: color-mix(in oklab, var(--color-background) 75%, black) !important; -} - -/* Focus states for accessibility */ -input[type="date"]:focus, -input[type="datetime-local"]:focus, -input[type="time"]:focus, -input[type="month"]:focus, -input[type="week"]:focus { - outline: 2px solid var(--color-primary); - outline-offset: 2px; - border-color: var(--color-primary); -} - -/* Industrial control UI patterns */ -.control-panel { - border-radius: 0.75rem; - border: 2px solid var(--color-border); - background: linear-gradient(135deg, var(--color-surface), var(--color-surface-raised)); - box-shadow: var(--shadow-md); - padding: 1.5rem; - position: relative; - overflow: hidden; -} - -.control-button { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border-radius: 0.5rem; - padding: 0.75rem 1rem; - font-size: 0.875rem; - font-weight: 600; - border: 2px solid; - transition: all 200ms; - position: relative; - overflow: hidden; -} - -.control-button.power-on { - border-color: #10b981; - background-color: #059669; - color: white; -} - -.control-button.power-off { - border-color: #ef4444; - background-color: #dc2626; - color: white; -} - -.control-button:disabled { - border-color: #94a3b8; - background-color: #94a3b8; - color: #64748b; - cursor: not-allowed; -} - -.status-badge { - border-radius: 0.25rem; - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 700; - font-family: 'Courier New', monospace; - color: var(--color-text); - background-color: var(--color-surface); - border: 1px solid var(--color-border); -} - -/* -------------------------------------------------------------------------- */ -/* Explicit Light Mode Overrides */ -/* When the user has forced light mode while the OS preference is dark, */ -/* we add .force-light on . Some component or plugin CSS (or browser */ -/* UA adjustments) still reacts to the system dark preference via */ -/* @media (prefers-color-scheme: dark). To neutralize those, we provide */ -/* targeted overrides within the same media query context. */ -/* -------------------------------------------------------------------------- */ -@media (prefers-color-scheme: dark) { - :root.force-light { - /* Tell the browser to render built-in widgets (scrollbars, form controls) as light */ - color-scheme: light !important; - - /* Override ALL design tokens to force light mode values */ - --color-primary: #4caf50 !important; - --color-primary-hover: #3a8c3f !important; - --color-background: #eaeaea !important; - --color-foreground: #938aee !important; - --color-foreground-light: #f0f0f0 !important; - --color-foreground-dark: #2a2a2a !important; - --color-card: #ffffff !important; - --color-text: #333333 !important; - --color-text-secondary: rgba(51, 51, 51, 0.7) !important; - - /* Specialized colors */ - --color-air-bg: #e6f7ff !important; - --color-air-border: #1e88e5 !important; - --color-soil-bg: #f6ffed !important; - --color-soil-border: #4caf50 !important; - - /* Utility colors */ - --color-error: #e53935 !important; - --color-error-bg: #ffebee !important; - --color-success: #43a047 !important; - --color-success-bg: #e8f5e9 !important; - - /* Shadow */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important; - - /* Standard UI colors */ - --color-border: rgb(229 231 235) !important; - --color-border-subtle: rgb(229 231 235 / 0.3) !important; - --color-surface: rgb(249 250 251) !important; - --color-surface-raised: rgb(255 255 255) !important; - --color-text-muted: rgb(107 114 128) !important; - - /* Button tokens */ - --btn-primary-bg: #4caf50 !important; - --btn-primary-bg-hover: #3a8c3f !important; - --btn-primary-text: white !important; - --btn-secondary-bg: #f0f0f0 !important; - --btn-secondary-bg-hover: #e0e0e0 !important; - --btn-secondary-text: #333333 !important; - - /* Panel tokens */ - --panel-bg-light: #f0f0f0 !important; - --panel-bg-dark: #2a2a2a !important; - } - :root.force-light, :root.force-light body { - background-color: var(--color-background) !important; - color: var(--color-text) !important; - } - :root.force-light .panel { - background: var(--panel-bg-light) !important; - color: var(--color-text) !important; - } - :root.force-light .card { - background: var(--color-card) !important; - color: var(--color-text) !important; - } - /* Ensure generic utility backgrounds expected for light remain light */ - :root.force-light .bg-foreground-dark { background-color: var(--color-foreground-light) !important; } - :root.force-light .bg-card-dark { background-color: var(--color-card) !important; } - - /* Force Tailwind utility classes to use light mode values */ - :root.force-light .bg-white { background-color: #ffffff !important; } - :root.force-light .bg-gray-50 { background-color: #f9fafb !important; } - :root.force-light .bg-gray-100 { background-color: #f3f4f6 !important; } - :root.force-light .bg-gray-200 { background-color: #e5e7eb !important; } - :root.force-light .bg-gray-300 { background-color: #d1d5db !important; } - :root.force-light .bg-gray-600 { background-color: #4b5563 !important; } - :root.force-light .bg-gray-700 { background-color: #374151 !important; } - :root.force-light .bg-gray-800 { background-color: #1f2937 !important; } - :root.force-light .bg-gray-900 { background-color: #111827 !important; } - - /* Blue backgrounds for buttons/accents */ - :root.force-light .bg-blue-50 { background-color: #eff6ff !important; } - :root.force-light .bg-blue-100 { background-color: #dbeafe !important; } - :root.force-light .bg-blue-700 { background-color: #1d4ed8 !important; } - - /* Status/accent colors */ - :root.force-light .bg-yellow-500 { background-color: #eab308 !important; } - :root.force-light .bg-green-500 { background-color: #22c55e !important; } - :root.force-light .bg-red-400.dark\:bg-slate-500 { background-color: #f87171 !important; } - - /* White/transparent backgrounds */ - :root.force-light .bg-white\/20 { background-color: rgba(255, 255, 255, 0.2) !important; } - - :root.force-light .text-white { color: #ffffff !important; } - :root.force-light .text-black { color: #000000 !important; } - :root.force-light .text-gray-300 { color: #d1d5db !important; } - :root.force-light .text-gray-400 { color: #9ca3af !important; } - :root.force-light .text-gray-500 { color: #6b7280 !important; } - :root.force-light .text-gray-600 { color: #4b5563 !important; } - :root.force-light .text-gray-700 { color: #374151 !important; } - :root.force-light .text-gray-800 { color: #1f2937 !important; } - :root.force-light .text-gray-900 { color: #111827 !important; } - - /* Blue text colors for accents */ - :root.force-light .text-blue-300 { color: #93c5fd !important; } - :root.force-light .text-blue-700 { color: #1d4ed8 !important; } - - /* Hover states */ - :root.force-light .hover\:bg-blue-100:hover { background-color: #dbeafe !important; } - :root.force-light .hover\:bg-gray-600:hover { background-color: #4b5563 !important; } - :root.force-light .hover\:bg-yellow-600:hover { background-color: #d97706 !important; } - :root.force-light .hover\:bg-yellow-400:hover { background-color: #fbbf24 !important; } - :root.force-light .hover\:bg-green-600:hover { background-color: #16a34a !important; } - :root.force-light .hover\:bg-green-400:hover { background-color: #4ade80 !important; } - - :root.force-light .border-white { border-color: #ffffff !important; } - :root.force-light .border-gray-200 { border-color: #e5e7eb !important; } - :root.force-light .border-gray-300 { border-color: #d1d5db !important; } - :root.force-light .border-gray-400 { border-color: #9ca3af !important; } - :root.force-light .border-gray-500 { border-color: #6b7280 !important; } - :root.force-light .border-gray-600 { border-color: #4b5563 !important; } - :root.force-light .border-gray-700 { border-color: #374151 !important; } - :root.force-light .border-gray-800 { border-color: #1f2937 !important; } - - /* Blue borders for buttons/accents */ - :root.force-light .border-blue-200 { border-color: #dbeafe !important; } - :root.force-light .border-blue-300 { border-color: #93c5fd !important; } - :root.force-light .border-blue-500 { border-color: #3b82f6 !important; } -} - - -/* Sensor Data Cards contrast variant -------------------------------------- */ -.sensor-card-surface { - background-color: color-mix(in oklab, var(--color-background) 96%, black); /* light: a hair darker */ - transition: background-color .25s, border-color .25s, color .25s; -} -.dark .sensor-card-surface { - background-color: color-mix(in oklab, var(--color-background) 90%, white); /* dark: a hair lighter */ -} -:root.force-light .sensor-card-surface { - background-color: color-mix(in oklab, var(--color-background) 96%, black) !important; -} -@media (hover: hover) { - .sensor-card-surface:hover { background-color: color-mix(in oklab, var(--color-background) 94%, black); } - .dark .sensor-card-surface:hover { background-color: color-mix(in oklab, var(--color-background) 88%, white); } - :root.force-light .sensor-card-surface:hover { background-color: color-mix(in oklab, var(--color-background) 94%, black) !important; } -} - -/* Standard spacing patterns - ensures consistency across components */ -.container-padding { - padding: 1rem; /* p-4 */ -} - -.container-padding-lg { - padding: 1.5rem; /* p-6 */ -} - -.section-gap { - gap: 1rem; /* gap-4 */ -} - -.item-gap { - gap: 0.5rem; /* gap-2 */ -} - -.content-spacing { - margin-bottom: 1rem; /* mb-4 */ -} - -.tight-spacing { - gap: 0.25rem; /* gap-1 */ -} - -/* Typography scale for consistency */ -.text-content { - font-size: 0.875rem; /* text-sm */ - line-height: 1.25rem; -} - -.text-label { - font-size: 0.75rem; /* text-xs */ - line-height: 1rem; - font-weight: 500; - color: var(--color-text-muted); -} diff --git a/src/app.d.ts b/src/app.d.ts index 8167efac..781dd554 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,34 +1,14 @@ -/// -/// - // See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces - -import type { Database } from '../database.types'; -import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -type TypedSupabaseClient = SupabaseClient; +import type { IJWT } from "$lib/interfaces/jwt.interface"; +// for information about these interfaces declare global { namespace App { // interface Error {} interface Locals { - supabase: TypedSupabaseClient; - safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; - session: Session | null; - user: User | null; - supabaseCookies?: Array<{ - name: string; - value: string; - options: { - path?: string; - httpOnly?: boolean; - secure?: boolean; - sameSite?: boolean | 'strict' | 'lax' | 'none'; - maxAge?: number; - domain?: string; - }; - }>; + jwt: IJWT | null; + jwtString: string | null; } // interface PageData {} // interface PageState {} @@ -36,6 +16,4 @@ declare global { } } -export {}; - -export {}; +export { }; diff --git a/src/app.html b/src/app.html index de3d879d..ca67dd58 100644 --- a/src/app.html +++ b/src/app.html @@ -1,30 +1,17 @@ - - - - - - - - - - %sveltekit.head% - - + + + - + - -
    %sveltekit.body%
    - + - \ No newline at end of file + %sveltekit.head% + + + +
    %sveltekit.body%
    + + diff --git a/src/error.html b/src/error.html new file mode 100644 index 00000000..018723bc --- /dev/null +++ b/src/error.html @@ -0,0 +1,112 @@ + + + + + + CropWatch — %sveltekit.error.message% + + + +
    +
    %sveltekit.status%
    +

    Something went wrong

    +

    %sveltekit.error.message%

    +
    + + Try Home +
    +
    + + diff --git a/src/hooks.client.ts b/src/hooks.client.ts index e69de29b..a65693de 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -0,0 +1,41 @@ +import type { HandleClientError } from '@sveltejs/kit'; + +function clearAllClientState(): void { + try { + localStorage.clear(); + } catch { + // storage may be unavailable + } + + try { + sessionStorage.clear(); + } catch { + // storage may be unavailable + } + + try { + const cookies = document.cookie.split(';'); + for (const cookie of cookies) { + const name = cookie.split('=')[0].trim(); + if (name) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + } + } catch { + // cookie clearing failed + } +} + +export const handleError: HandleClientError = ({ error, status, message }) => { + console.error('[CropWatch] Unexpected client error:', error); + + if (status === 500 || status === undefined) { + clearAllClientState(); + window.location.href = '/auth/login?reason=error-recovery'; + return; + } + + return { + message + }; +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7b042bee..37b47cb7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,316 +1,166 @@ -import { error, redirect, type Handle } from '@sveltejs/kit'; -import { createServerClient } from '@supabase/ssr'; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; -import { createClient } from '@supabase/supabase-js'; -import type { Session, User } from '@supabase/supabase-js'; -import type { Database } from '../database.types'; - -const PUBLIC_ROUTES = [ - '/offline.html', - '/auth', // All routes under /auth/ - '/legal', - '/api/auth', // Only authentication-related API routes - '/api/webhook', // Webhook endpoints (authenticated via webhook signatures) - '/static', // All static assets - '/static/icons', - '/static/screenshots' -]; - -// Additional check for exact /api/ route -const isExactApiRoute = (pathname: string) => pathname === '/api' || pathname === '/api/'; - -// Handle for CORS -const handleCORS: Handle = async ({ event, resolve }) => { - // Apply CORS header for API routes and preflight requests - if (event.url.pathname.startsWith('/api')) { - // Handle preflight requests - if (event.request.method === 'OPTIONS') { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, apikey, X-Requested-With, X-Refresh-Token', - 'Access-Control-Max-Age': '86400' - } - }); - } - } +import { sequence } from '@sveltejs/kit/hooks'; +import { buildLoginPath } from '$lib/utils/auth-redirect'; +import { getTextDirection } from '$lib/paraglide/runtime'; +import { paraglideMiddleware } from '$lib/paraglide/server'; +import type { IJWT } from '$lib/interfaces/jwt.interface'; +import type { Handle, HandleFetch, HandleServerError, RequestEvent } from '@sveltejs/kit'; +import { isRedirect, redirect } from '@sveltejs/kit'; +import { jwtDecode } from 'jwt-decode'; +import { env as publicEnv } from '$env/dynamic/public'; + +const PUBLIC_PATHS = new Set([ + '/manifest.webmanifest', + '/offline', + '/offline/', + '/service-worker.js' +]); +const PUBLIC_API_BASE_URL = publicEnv.PUBLIC_API_BASE_URL ?? ''; + +function getAuthRedirectTarget(event: RequestEvent): string { + return `${event.url.pathname}${event.url.search}`; +} + +export const originalHandle: Handle = async ({ event, resolve }) => { + const token = event.cookies.get('jwt'); + + checkAuthToken(token ?? '', event); const response = await resolve(event); - // Add CORS headers to API responses - if (event.url.pathname.startsWith('/api')) { - response.headers.set('Access-Control-Allow-Origin', '*'); - response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); - response.headers.set( - 'Access-Control-Allow-Headers', - 'Content-Type, Authorization, apikey, X-Requested-With, X-Refresh-Token' - ); - } - - return response; + return ensureHtmlCharset(response); }; -// Handle for Supabase authentication and session management -const handleSupabase: Handle = async ({ event, resolve }) => { - // Create a Supabase client specific for server-side rendering (SSR) - event.locals.supabase = createServerClient( - PUBLIC_SUPABASE_URL, - PUBLIC_SUPABASE_ANON_KEY, - { - cookies: { - getAll: () => event.cookies.getAll(), - setAll: (cookiesToSet) => { - // Store cookies to be set later instead of setting them immediately - event.locals.supabaseCookies = cookiesToSet; - } - } - } - ) as any; - - // Handle JWT token authentication for API routes - let tokenSession: Session | null = null; - let tokenUser: User | null = null; +const ensureHtmlCharset = (response: Response): Response => { + const contentType = response.headers.get('content-type'); - // Get headers from the request for better debugging - const headers = new Headers(event.request.headers); + if (!contentType) { + return response; + } - // Check for authorization headers (case-insensitive) - const authorizationHeader = headers.get('authorization') || headers.get('Authorization'); - const refreshTokenHeader = headers.get('x-refresh-token') || headers.get('X-Refresh-Token'); + const isHtml = contentType.toLowerCase().includes('text/html'); + const hasCharset = contentType.toLowerCase().includes('charset='); - // Extract tokens - const jwt = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); - const refreshToken = refreshTokenHeader?.trim(); + if (!isHtml || hasCharset) { + return response; + } - // If we have a token and this is an API route, try to validate it - const isApiOrAppRoute = - event.url.pathname.startsWith('/api') || event.url.pathname.includes('/reports/pdf'); + const headers = new Headers(response.headers); + headers.set('content-type', `${contentType}; charset=utf-8`); - if (jwt && isApiOrAppRoute) { - const bearer = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); - if (!bearer) { - throw error(401, 'Unauthorized access: No JWT token provided'); - } - const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { - headers: { Authorization: `Bearer ${bearer}` } - }, - auth: { persistSession: false } - }); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }); +}; - const [{ data: sessionData, error: sessionError }, { data: userData, error: userError }] = - await Promise.all([jwtSupabase.auth.getSession(), jwtSupabase.auth.getUser()]); +const checkAuthToken = (token: string, event: RequestEvent) => { + const authRoute = event.url.pathname.startsWith('/auth'); + const publicRoute = PUBLIC_PATHS.has(event.url.pathname); + const bypassAuth = authRoute || publicRoute; - if (sessionError) { - console.error('JWT session lookup error:', sessionError.message); - } + if (token) { + let decodedJWT: IJWT; - if (userError || !userData?.user) { - throw error(401, 'Unauthorized access: Invalid JWT token'); - } + try { + decodedJWT = jwtDecode(token) as IJWT; + } catch (error) { + if (isRedirect(error)) { + throw error; + } - tokenSession = sessionData.session ?? null; - tokenUser = userData.user; + if (!bypassAuth) { + event.locals.jwt = null; + event.locals.jwtString = null; - event.locals.supabase = jwtSupabase as any; - return await resolve(event, { - filterSerializedResponseHeaders(name) { - return name === 'content-range' || name === 'x-supabase-api-version'; + throw redirect( + 303, + buildLoginPath({ + redirectTo: getAuthRedirectTarget(event), + reason: 'auth-required' + }) + ); } - }); - } - // Enhance session validation to include explicit debug logging - event.locals.safeGetSession = async () => { - // ✅ If JWT token has already been validated, return user even if no session - if (tokenUser && event.url.pathname.startsWith('/api')) { - console.log('Using tokenUser from earlier validation (safeGetSession)'); - return { session: tokenSession, user: tokenUser }; // tokenSession may be null — that's okay + return false; } - // 🧪 Else, try to get session from Supabase client (cookie-based) - try { - // console.log(`Session validation for path: ${event.url.pathname}`); - const { - data: { session } - } = await event.locals.supabase.auth.getSession(); - - if (!session) { - console.warn(`No session found during validation for path: ${event.url.pathname}`); - return { session: null, user: null }; - } + event.locals.jwt = decodedJWT; + event.locals.jwtString = token; + + const now = Math.floor(Date.now() / 1000); - const { - data: { user }, - error - } = await event.locals.supabase.auth.getUser(); + if (decodedJWT.exp < now) { + console.log('JWT token expired, redirecting to login'); + event.cookies.delete('jwt', { path: '/' }); + event.locals.jwt = null; + event.locals.jwtString = null; - if (error) { - console.error( - `Error validating user session for path: ${event.url.pathname}`, - error.message + if (!bypassAuth) { + throw redirect( + 303, + buildLoginPath({ + redirectTo: getAuthRedirectTarget(event), + reason: 'expired' + }) ); - return { session: null, user: null }; } - return { session, user }; - } catch (err) { - return { session: null, user: null }; + + return false; } - }; - // Execute session validation once and store results in locals - const sessionData = await event.locals.safeGetSession(); - - // We need to run this after Supabase handle to have access to session data - // This will be called in the correct order in our sequence - const pathname = event.url.pathname; - - // Check if current route requires authentication - const isPublicRoute = - PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')) || - isExactApiRoute(pathname); - - // Special handling for API routes with JWT tokens - if (pathname.startsWith('/api') && !sessionData.session && !isPublicRoute) { - // For API device-data routes, check if we have a token in the Authorization header - const headers = event.request.headers; - const authHeader = headers.get('authorization') || headers.get('Authorization'); - const apiToken = authHeader?.replace(/^Bearer\s+/i, '').trim(); - - // If we have a token, validate it before proceeding - if (apiToken) { - // console.log('Validating API token for:', pathname); - - try { - // Validate the token using Supabase auth - const { data, error } = await event.locals.supabase.auth.getUser(apiToken); - - if (error || !data?.user) { - console.error('Invalid API token:', error?.message || 'User not found'); - return new Response(JSON.stringify({ error: 'Invalid API token' }), { - status: 401, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, apikey, X-Requested-With, X-Refresh-Token' - } - }); - } - - // Token is valid, set the user from the validated token - //console.log('Valid API token for user:', data.user.email); - event.locals.user = data.user; - tokenUser = data.user; - tokenSession = null; - - // Continue processing the request - const response = await resolve(event, { - filterSerializedResponseHeaders(name) { - return name === 'content-range' || name === 'x-supabase-api-version'; - } - }); - return response; - } catch (err) { - console.error('Error validating API token:', err); - return new Response(JSON.stringify({ error: 'Error validating API token' }), { - status: 401, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, apikey, X-Requested-With, X-Refresh-Token' - } - }); - } + return true; + } else { + if (!bypassAuth) { + console.log('No JWT token found, redirecting to login'); + event.locals.jwt = null; + event.locals.jwtString = null; + + throw redirect( + 303, + buildLoginPath({ + redirectTo: getAuthRedirectTarget(event), + reason: 'auth-required' + }) + ); } } - if (!isPublicRoute && !sessionData.session) { - // For API routes, return 401 Unauthorized instead of redirecting - if (pathname.startsWith('/api')) { - // console.log(`Unauthorized API access attempt: ${pathname}`); - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { - 'Content-Type': 'application/json' - } - }); - } + return false; +}; - // console.log(`Redirecting unauthenticated user from protected route: ${pathname}`); - throw redirect(302, '/auth/login'); - } +const handleFetch: HandleFetch = async ({ request, fetch, event }) => { + if (PUBLIC_API_BASE_URL && request.url.startsWith(PUBLIC_API_BASE_URL)) { + const token = event.cookies.get('jwt'); - // Store validated session and user in locals for routes to use - event.locals.session = sessionData.session; - event.locals.user = sessionData.user; - // console.log('Session validated and stored in locals:', sessionData.user?.email || 'Guest'); + checkAuthToken(token ?? '', event); - // Resolve the response - const response = await resolve(event, { - filterSerializedResponseHeaders(name) { - return name === 'content-range' || name === 'x-supabase-api-version'; + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); } - }); - - // After the response is generated, apply the stored cookies if they exist - if (event.locals.supabaseCookies) { - event.locals.supabaseCookies.forEach(({ name, value, options }) => { - // Format sameSite value correctly - let sameSiteValue: string | undefined; - if (options.sameSite === true) sameSiteValue = 'Strict'; - else if (options.sameSite === false) sameSiteValue = 'None'; - else if (options.sameSite) - sameSiteValue = options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1); - - // Construct cookie string - const cookieHeader = [ - `${name}=${value}`, - `Path=${options.path || '/'}`, - options.httpOnly ? 'HttpOnly' : '', - options.secure ? 'Secure' : '', - sameSiteValue ? `SameSite=${sameSiteValue}` : '', - options.maxAge ? `Max-Age=${options.maxAge}` : '', - options.domain ? `Domain=${options.domain}` : '' - ] - .filter(Boolean) - .join('; '); - - response.headers.append('set-cookie', cookieHeader); - }); } - return response; + return fetch(request); }; -// Combine the handles - CORS first, then Supabase -export const handle: Handle = async ({ event, resolve }) => { - const { url } = event; - if ( - url.pathname.startsWith('/sw.js') || - url.pathname.startsWith('/workbox-') || - url.pathname.startsWith('/manifest.webmanifest') || - url.pathname.startsWith('/registerSW.js') || - url.pathname.startsWith('/offline.html') - ) { - return resolve(event); - } +const paraglideHandle: Handle = ({ event, resolve }) => + paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { + event.request = localizedRequest; - // First apply CORS (handles preflight requests immediately) - return await handleCORS({ - event, - resolve: async (corsEvent) => { - // Then apply Supabase handle (which includes route guards and may throw redirects) - return await handleSupabase({ - event: corsEvent, - resolve - }); - } + return resolve(event, { + transformPageChunk: ({ html }) => + html + .replace('%paraglide.lang%', locale) + .replace('%paraglide.dir%', getTextDirection(locale)) + }); }); + +export const handle = sequence(paraglideHandle, originalHandle); + +export const handleError: HandleServerError = ({ error, status, message }) => { + console.error('[CropWatch] Unexpected server error:', error); + + return { + message: status === 404 ? 'Not Found' : 'Internal Error' + }; }; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 00000000..e75600b3 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,3 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; + +export const reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/src/lib/actions/clickOutside.ts b/src/lib/actions/clickOutside.ts deleted file mode 100644 index 427f436f..00000000 --- a/src/lib/actions/clickOutside.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { browser } from '$app/environment'; - -export function clickOutside(node: HTMLElement, callback: () => void) { - if (!browser) return { destroy: () => {} }; - - // Flag to prevent the action from being triggered during initialization - let isInitialized = false; - - setTimeout(() => { - isInitialized = true; - }, 100); - - function handleClick(event: MouseEvent) { - if (!isInitialized) return; - - // Check if the click was outside the node - if (node && !node.contains(event.target as Node) && event.target !== node) { - callback(); - } - } - - // Use capture phase to ensure we handle the event before other handlers - document.addEventListener('click', handleClick, true); - - return { - destroy() { - document.removeEventListener('click', handleClick, true); - } - }; -} diff --git a/src/lib/actions/formValidation.ts b/src/lib/actions/formValidation.ts deleted file mode 100644 index dc787b88..00000000 --- a/src/lib/actions/formValidation.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function formValidation(form: HTMLFormElement) { - const submit = form.querySelector('[type="submit"]') as HTMLButtonElement | HTMLInputElement | null; - if (submit) submit.disabled = true; - const validate = () => { - const valid = form.checkValidity(); - if (submit) submit.disabled = !valid; - }; - validate(); - form.addEventListener('input', validate); - form.addEventListener('change', validate); - return { - destroy() { - form.removeEventListener('input', validate); - form.removeEventListener('change', validate); - } - }; -} diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts new file mode 100644 index 00000000..6394988a --- /dev/null +++ b/src/lib/api/api.dtos.ts @@ -0,0 +1,407 @@ +import type { CwDevice } from '../interfaces/CwDevice.interface.ts'; +import type { CwDeviceOwner } from '../interfaces/CwDeviceOwner.interface.ts'; +import type { CwDeviceType } from '../interfaces/CwDeviceTypeDto.interface.ts'; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + message: string; + data?: Record; + result?: { + accessToken?: string; + expires_at_datetime?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type DeviceOwnerDto = CwDeviceOwner; + +export type DeviceTypeDto = CwDeviceType; + +export interface DeviceDto extends CwDevice { + cw_device_type: DeviceTypeDto; + cw_locations?: Record | Array>; + cw_device_owners?: DeviceOwnerDto[]; +} + +export interface CreateDeviceOwnerRequest { + user_id: string; + permission_level: number; + dev_eui?: string; + owner_id?: number | null; + [key: string]: unknown; +} + +export interface CreateDeviceRequest extends Partial { + dev_eui: string; + name: string; + type?: number | null; + location_id?: number | null; + cw_device_owners?: CreateDeviceOwnerRequest[]; +} + +export type UpdateDeviceRequest = Partial; + +export interface DevicePrimaryDataDto { + dev_eui: string; + created_at: string; + name?: string; + location_id?: number | null; + location_name?: string; + group?: string | null; + co2?: number | null; + humidity?: number | null; + temperature_c?: number | null; + [key: string]: unknown; +} + +export interface DeviceStatusSummary { + online: number; + offline: number; + [key: string]: unknown; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + [key: string]: unknown; +} + +export type ListOrPaginatedResponse = T[] | PaginatedResponse; + +export interface TimeRangeQuery { + start?: string | Date; + end?: string | Date; + timezone?: string; +} + +export interface PaginationQuery { + skip?: number; + take?: number; +} + +export interface DeviceListQuery extends PaginationQuery { + dev_eui?: string; + group?: string; + name?: string; + location?: string; +} + +export interface DeviceDataWithinRangeQuery extends PaginationQuery { + start?: string | Date; + end?: string | Date; + timezone?: string; +} + +export interface LatestPrimaryDataQuery extends PaginationQuery { + dev_eui?: string; + group?: string; + locationGroup?: string; + location?: string; + name?: string; +} + +export interface SensorTimeSeriesPoint { + created_at: string; + dev_eui: string; + [key: string]: unknown; +} + +export interface WaterDataPoint extends SensorTimeSeriesPoint { + id: number; +} + +export interface TrafficDataPoint extends SensorTimeSeriesPoint { + id: number; +} + +export interface TrafficMonthlyReportDto { + traffic_day: string; + total_people: number; + total_bicycles: number; + total_vehicles: number; +} + +export interface TrafficMonthlyQuery { + year: number; + month: number; + timezone?: string; +} + +export interface PulseRelayRequest { + relay: number; + durationSeconds: number; +} + +export interface UpdateRelayRequest { + relay: number; + targetState: 'on' | 'off'; +} + +export interface RuleCriteriaDto { + id: number; + operator: string; + ruleGroupId: string; + subject: string; + trigger_value: number; + created_at?: string; + criteria_id?: string | null; + parent_id?: string | null; + reset_value?: number | null; + [key: string]: unknown; +} + +export interface RulesQuery { + name?: string; +} + +export interface RuleDto { + id: number; + name: string; + action_recipient: string; + notifier_type: number; + ruleGroupId: string; + profile_id: string; + dev_eui?: string | null; + send_using?: string | null; + is_triggered: boolean; + trigger_count: number; + created_at: string; + last_triggered?: string | null; + cw_rule_criteria?: RuleCriteriaDto[]; + [key: string]: unknown; +} + +export interface CreateRuleRequest extends Partial { + action_recipient: string; + name: string; + notifier_type: number; + ruleGroupId: string; +} + +export type UpdateRuleRequest = Partial; + +export interface ReportUserScheduleDto { + id?: number; + dev_eui: string; + report_user_schedule_id?: number; + is_active?: boolean; + end_of_week?: boolean; + end_of_month?: boolean; + user_id?: string; + report_id?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface CreateReportUserScheduleRequest { + created_at?: string; + dev_eui: string; + end_of_month?: boolean; + end_of_week?: boolean; + id?: number; + is_active?: boolean; + report_id?: string | null; + report_user_schedule_id?: number; + user_id?: string; + [key: string]: unknown; +} + +export interface ReportAlertPointDto { + id?: number; + data_point_key: string; + name: string; + report_id: string; + user_id?: string; + value?: number | null; + min?: number | null; + max?: number | null; + operator?: string | null; + hex_color?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface CreateReportAlertPointRequest { + created_at?: string; + data_point_key: string; + hex_color?: string | null; + id?: number; + max?: number | null; + min?: number | null; + name: string; + operator?: string | null; + report_id?: string | null; + user_id?: string | null; + value?: number | null; + [key: string]: unknown; +} + +export interface ReportRecipientDto { + id?: number; + communication_method: number; + report_id: string; + name?: string | null; + email?: string | null; + user_id?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface CreateReportRecipientRequest { + communication_method: number; + created_at?: string; + email?: string | null; + id?: number; + name?: string | null; + report_id?: string | null; + user_id?: string | null; + [key: string]: unknown; +} + +export interface ReportDataProcessingScheduleDto { + created_at: string; + crosses_midnight: boolean; + day_of_week: number; + end_time: string; + id: string; + is_enabled: boolean; + report_id: string; + rule_type: string; + start_time: string; + timezone: string; + updated_at: string; + valid_from?: unknown | null; + valid_to?: unknown | null; + [key: string]: unknown; +} + +export interface CreateReportDataProcessingScheduleRequest { + created_at?: string; + crosses_midnight?: boolean; + day_of_week: number; + end_time: string; + id?: string; + is_enabled?: boolean; + report_id?: string; + rule_type?: string; + start_time: string; + timezone?: string; + updated_at?: string; + valid_from?: unknown | null; + valid_to?: unknown | null; + [key: string]: unknown; +} + +export interface ReportsQuery { + name?: string; +} + +export interface ReportDto { + id: number; + report_id: string; + name: string; + dev_eui: string; + created_at: string; + user_id?: string | null; + report_user_schedule?: ReportUserScheduleDto[]; + report_alert_points?: ReportAlertPointDto[]; + report_recipients?: ReportRecipientDto[]; + report_data_processing_schedules?: ReportDataProcessingScheduleDto[]; + [key: string]: unknown; +} + +export interface CreateReportRequest { + created_at?: string; + dev_eui: string; + id?: number; + name: string; + report_id?: string; + user_id?: string | null; + report_user_schedule?: CreateReportUserScheduleRequest[]; + report_alert_points?: CreateReportAlertPointRequest[]; + report_recipients?: CreateReportRecipientRequest[]; + report_data_processing_schedules?: CreateReportDataProcessingScheduleRequest[]; +} + +export type UpdateReportRequest = Partial; + +export type TriggeredRulesCountResponse = + | number + | { + count: number; + [key: string]: unknown; + }; + +export interface LocationOwnerDto { + admin_user_id: string; + description?: Record | null; + id: number; + is_active?: Record | null; + location_id: number; + owner_id: number; + permission_level?: Record | null; + user_id: string; + [key: string]: unknown; +} + +export interface LocationsQuery { + name?: string; +} + +export interface LocationDto { + created_at: string; + description?: Record | null; + group?: Record | null; + lat?: Record | null; + location_id: number; + long?: Record | null; + map_zoom?: Record | null; + name: string; + owner_id?: Record | null; + cw_location_owners?: LocationOwnerDto[]; + [key: string]: unknown; +} + +export interface CreateLocationOwnerRequest { + admin_user_id: string; + location_id: number; + user_id: string; + description?: Record | null; + id?: number; + is_active?: Record | null; + owner_id?: number; + permission_level?: Record | null; + [key: string]: unknown; +} + +export interface UpdateLocationOwnerRequest { + admin_user_id?: string; + location_id?: number; + user_id?: string; + description?: Record | null; + id?: number; + is_active?: Record | null; + owner_id?: number; + permission_level?: Record | null; + [key: string]: unknown; +} + +export interface CreateLocationRequest { + name: string; + description?: string | null; + group?: string | null; + lat?: number | null; + long?: number | null; + [key: string]: unknown; +} + +export type UpdateLocationRequest = { + name: string; + description?: string | null; + group?: string | null; +}; diff --git a/src/lib/api/api.service.spec.ts b/src/lib/api/api.service.spec.ts new file mode 100644 index 00000000..b4e0519f --- /dev/null +++ b/src/lib/api/api.service.spec.ts @@ -0,0 +1,351 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiService } from './api.service'; + +function createJsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { + 'content-type': 'application/json' + } + }); +} + +describe('ApiService created_at timezone handling', () => { + it('converts response created_at fields recursively using the configured offset', async () => { + const fetchFn = vi.fn(async () => + createJsonResponse({ + created_at: '2026-03-08 12:00:00', + nested: { + Created_at: '2026-03-08T13:30:45' + }, + items: [{ created_at: '2026-03-08T23:15:00Z' }, { created_at: '2026-03-09T00:45:00+00:00' }] + }) + ) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn, + timeZoneOffset: -5 + }); + + const result = await api.getDeviceLatestData('device-123'); + + expect(result).toEqual({ + created_at: '2026-03-08T07:00:00.000-05:00', + nested: { + Created_at: '2026-03-08T08:30:45.000-05:00' + }, + items: [ + { created_at: '2026-03-08T18:15:00.000-05:00' }, + { created_at: '2026-03-08T19:45:00.000-05:00' } + ] + }); + }); + + it('serializes created_at fields back to UTC before sending request bodies', async () => { + let serializedBody = ''; + + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + serializedBody = String(init?.body ?? ''); + return createJsonResponse(null); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn, + timeZoneOffset: -5 + }); + + await api.createAirNote({ + note: 'Check telemetry row', + created_at: '2026-03-08T07:00:00.000-05:00', + dev_eui: 'device-123' + }); + + expect(JSON.parse(serializedBody)).toEqual({ + note: 'Check telemetry row', + created_at: '2026-03-08T12:00:00.000Z', + dev_eui: 'device-123' + }); + }); + + it('forwards timezone when requesting device data within range', async () => { + let requestedUrl = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + requestedUrl = String(input); + return createJsonResponse([]); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.getDeviceDataWithinRange('device-123', { + start: '2026-03-08T12:00:00.000Z', + end: '2026-03-08T13:00:00.000Z', + timezone: 'America/New_York' + }); + + expect(requestedUrl).toContain('timezone=America%2FNew_York'); + }); +}); + +describe('ApiService list response normalization', () => { + it('normalizes wrapped location device payloads exposed under result', async () => { + const fetchFn = vi.fn(async () => + createJsonResponse({ + result: [ + { + dev_eui: 'dev-1', + name: 'North sensor', + created_at: '2026-03-12T08:00:00Z', + location_id: 42 + }, + { + dev_eui: 'dev-2', + name: 'South sensor', + created_at: '2026-03-12T08:05:00Z', + location_id: 42 + } + ], + count: 2 + }) + ) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + const result = await api.getAllLocationDevices(42); + + expect(result).toEqual({ + data: [ + { + dev_eui: 'dev-1', + name: 'North sensor', + created_at: '2026-03-12T08:00:00.000+00:00', + location_id: 42 + }, + { + dev_eui: 'dev-2', + name: 'South sensor', + created_at: '2026-03-12T08:05:00.000+00:00', + location_id: 42 + } + ], + total: 2 + }); + }); + + it('normalizes wrapped latest primary device payloads for the dashboard', async () => { + const fetchFn = vi.fn(async () => + createJsonResponse({ + result: { + items: [ + { + dev_eui: 'dev-1', + name: 'North sensor', + created_at: '2026-03-12T08:00:00Z', + location_id: 42, + location_name: 'North Room', + temperature_c: 24.2, + humidity: 55, + co2: 810 + }, + { + dev_eui: 'dev-2', + name: 'South sensor', + created_at: '2026-03-12T08:05:00Z', + location_id: 42, + location_name: 'North Room', + temperature_c: 24.1, + humidity: 54, + co2: 805 + } + ], + total: 2 + } + }) + ) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + const result = await api.getLatestPrimaryDeviceData({ skip: 0, take: 25 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.data[0]).toMatchObject({ + dev_eui: 'dev-1', + name: 'North sensor', + location_id: 42, + location_name: 'North Room' + }); + }); + + it('normalizes wrapped device lists for dashboard fallback enumeration', async () => { + const fetchFn = vi.fn(async () => + createJsonResponse({ + result: [ + { + dev_eui: 'dev-1', + name: 'North sensor', + cw_device_type: { data_table_v2: 'cw_air_data' } + }, + { + dev_eui: 'dev-2', + name: 'South sensor', + cw_device_type: { data_table_v2: 'cw_air_data' } + } + ] + }) + ) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + const result = await api.getDevices(); + + expect(result).toHaveLength(2); + expect(result.map((device) => device.dev_eui)).toEqual(['dev-1', 'dev-2']); + }); + + it('fetches every device page when the API paginates /devices', async () => { + const firstPageDevices = Array.from({ length: 1000 }, (_, index) => ({ + dev_eui: `dev-${index + 1}`, + name: `Sensor ${index + 1}`, + cw_device_type: { data_table_v2: 'cw_air_data' } + })); + const secondPageDevices = [ + { + dev_eui: 'dev-1001', + name: 'Sensor 1001', + cw_device_type: { data_table_v2: 'cw_air_data' } + } + ]; + + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const skip = Number(url.searchParams.get('skip') ?? 0); + + if (skip === 0) { + return createJsonResponse({ + data: firstPageDevices, + total: 1001 + }); + } + + return createJsonResponse({ + data: secondPageDevices, + total: 1001 + }); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + const result = await api.getAllDevices(); + + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(1001); + expect(result[0]?.dev_eui).toBe('dev-1'); + expect(result[1000]?.dev_eui).toBe('dev-1001'); + }); +}); + +describe('ApiService relay endpoints', () => { + it('uses GET for the latest relay state', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + return createJsonResponse({ + id: 'relay-row-1', + last_update: '2026-04-05T10:00:00.000Z', + relay_1: true, + relay_2: false + }); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.getRelayData('device-123'); + + expect(requestedMethod).toBe('GET'); + expect(requestedUrl).toBe('https://example.com/relay/device-123'); + }); + + it('uses PATCH for manual relay state changes', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + let requestedBody = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + requestedBody = String(init?.body ?? ''); + return createJsonResponse(null); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.updateRelay('device-123', { + relay: 1, + targetState: 'on' + }); + + expect(requestedMethod).toBe('PATCH'); + expect(requestedUrl).toBe('https://example.com/relay/device-123'); + expect(JSON.parse(requestedBody)).toEqual({ + relay: 1, + targetState: 'on' + }); + }); + + it('uses POST for relay pulse commands', async () => { + let requestedUrl = ''; + let requestedMethod = ''; + let requestedBody = ''; + + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + requestedMethod = String(init?.method ?? 'GET'); + requestedBody = String(init?.body ?? ''); + return createJsonResponse(null); + }) as typeof fetch; + + const api = new ApiService({ + baseUrl: 'https://example.com', + fetchFn + }); + + await api.pulseRelay('device-123', { + durationSeconds: 30, + relay: 2 + }); + + expect(requestedMethod).toBe('POST'); + expect(requestedUrl).toBe('https://example.com/relay/device-123/pulse'); + expect(JSON.parse(requestedBody)).toEqual({ + durationSeconds: 30, + relay: 2 + }); + }); +}); diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts new file mode 100644 index 00000000..a13fda07 --- /dev/null +++ b/src/lib/api/api.service.ts @@ -0,0 +1,1129 @@ +import { env as publicEnv } from '$env/dynamic/public'; +import { buildLoginPath } from '$lib/utils/auth-redirect'; +import type { PdfFile } from '../interfaces/PdfFile.interface'; +import type { + CreateDeviceRequest, + CreateLocationRequest, + CreateReportRequest, + CreateRuleRequest, + DeviceDataWithinRangeQuery, + DeviceListQuery, + DeviceDto, + DevicePrimaryDataDto, + DeviceTypeDto, + DeviceStatusSummary, + LatestPrimaryDataQuery, + ListOrPaginatedResponse, + LocationDto, + LocationsQuery, + LoginRequest, + LoginResponse, + PaginatedResponse, + PaginationQuery, + ReportDto, + ReportsQuery, + RuleDto, + RulesQuery, + SensorTimeSeriesPoint, + TimeRangeQuery, + TrafficDataPoint, + TrafficMonthlyQuery, + TrafficMonthlyReportDto, + TriggeredRulesCountResponse, + PulseRelayRequest, + UpdateRelayRequest, + UpdateLocationRequest, + UpdateLocationOwnerRequest, + UpdateReportRequest, + UpdateRuleRequest, + WaterDataPoint +} from './api.dtos'; + +type FetchLike = typeof fetch; + +export type ApiQueryValue = string | number | boolean | Date | null | undefined; +export type ApiQuery = Record; + +export interface ApiErrorResponse { + statusCode?: number; + error?: string; + message?: string | string[] | Record; + [key: string]: unknown; +} + +export class ApiServiceError extends Error { + public readonly status: number; + public readonly payload: unknown; + + public constructor(status: number, statusText: string, payload: unknown) { + super(`CropWatch API request failed (${status} ${statusText})`); + this.name = 'ApiServiceError'; + this.status = status; + this.payload = payload; + } +} + +export interface ApiServiceOptions { + baseUrl?: string; + fetchFn?: FetchLike; + authToken?: string | null; + timeZoneOffset?: number; +} + +interface ApiRequestOptions extends Omit { + body?: unknown; + headers?: HeadersInit; + query?: ApiQuery; + authToken?: string | null; + responseType?: 'json' | 'text'; + suppressErrorStatuses?: number[]; +} + +type ApiMethodOptions = Pick; + +interface GetDeviceLatestDataOptions extends ApiMethodOptions { + suppressNotFoundError?: boolean; +} + +interface GetRelayDataOptions extends ApiMethodOptions { + suppressNotFoundError?: boolean; +} + +const CREATED_AT_KEY = 'created_at'; +const MINUTES_PER_HOUR = 60; +const MILLISECONDS_PER_MINUTE = 60_000; +const TIMEZONE_SUFFIX_PATTERN = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/; +const API_BASE_URL = publicEnv.PUBLIC_API_BASE_URL ?? ''; +const LOGIN_ENDPOINT = publicEnv.PUBLIC_LOGIN_ENDPOINT ?? '/auth/login'; +const DEVICE_STATUS_ENDPOINT = publicEnv.PUBLIC_DEVICE_STATUS_ENDPOINT ?? '/devices/status'; +const DEVICE_LATEST_PRIMARY_ENDPOINT = + publicEnv.PUBLIC_DEVICE_LATEST_PRIMARY_DATA_ENDPOINT ?? '/devices/latest-primary-data'; +const DEVICE_LATEST_PRIMARY_BY_DEV_EUI_ENDPOINT = + publicEnv.PUBLIC_DEVICE_LATEST_PRIMARY_DATA_BY_DEV_EUI_ENDPOINT ?? + '/devices/{dev_eui}/latest-primary-data'; + +const AUTH_ENDPOINT = '/auth'; +const AUTH_USER_PROFILE_ENDPOINT = '/auth/user-profile'; +const AIR_ENDPOINT = '/air/{dev_eui}'; +const DEVICES_ENDPOINT = '/devices'; +const DEVICE_TYPES_ENDPOINT = '/devices/device-types'; +const DEVICE_BY_DEV_EUI_ENDPOINT = '/devices/{dev_eui}'; +const DEVICE_DATA_ENDPOINT = '/devices/{dev_eui}/data'; +const DEVICE_DATA_WITHIN_RANGE_ENDPOINT = '/devices/{dev_eui}/data-within-range'; +const DEVICE_LATEST_DATA_ENDPOINT = '/devices/{dev_eui}/latest-data'; +const LOCATIONS_ENDPOINT = '/locations'; +const LOCATION_BY_ID_ENDPOINT = '/locations/{id}'; +const LOCATION_PERMISSION_ENDPOINT = '/locations/{id}/permission'; +const LOCATION_PERMISSION_UPDATE_PERMISSION_LEVEL_ENDPOINT = '/locations/{id}/permission-level'; +const POWER_ENDPOINT = '/power/{id}'; +const RELAY_ENDPOINT = '/relay/{dev_eui}'; +const RELAY_PULSE_ENDPOINT = '/relay/{dev_eui}/pulse'; +const PAYMENTS_PRODUCTS_ENDPOINT = '/payments/products'; +const PAYMENTS_SUBSCRIPTIONS_ENDPOINT = '/payments/subscriptions'; +const PAYMENTS_SUBSCRIPTION_STATE_ENDPOINT = '/payments/subscriptions/state'; +const PAYMENTS_SUBSCRIPTIONS_CHECKOUT_ENDPOINT = '/payments/subscriptions/checkout'; +const PAYMENTS_SUBSCRIPTIONS_PORTAL_ENDPOINT = '/payments/subscriptions/portal'; +const REPORTS_ENDPOINT = '/reports'; +const REPORTS_BASE_ENDPOINT = publicEnv.PUBLIC_REPORTS_ENDPOINT ?? REPORTS_ENDPOINT; +const REPORT_BY_REPORT_ID_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/{report_id}`; +const RULES_BASE_ENDPOINT = publicEnv.PUBLIC_RULES_ENDPOINT ?? '/rules'; +const TRIGGERED_RULES_BASE_ENDPOINT = + publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT ?? `${RULES_BASE_ENDPOINT}/triggered`; +const TRIGGERED_RULES_COUNT_ENDPOINT = + publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT_COUNT ?? `${TRIGGERED_RULES_BASE_ENDPOINT}/count`; +const REPORT_HISTORY_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/history/{dev_eui}`; +const REPORT_DOWNLOAD_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/download/{dev_eui}/{report_id}/{reportName}`; +const RULE_BY_ID_ENDPOINT = `${RULES_BASE_ENDPOINT}/{id}`; +const AIR_NOTES_CREATE_ENDPOINT = publicEnv.PUBLIC_AIR_NOTES_ENDPOINT ?? '/air-notes'; +const SOIL_ENDPOINT = '/soil/{dev_eui}'; +const TRAFFIC_ENDPOINT = '/traffic/{dev_eui}'; +const TRAFFIC_MONTHLY_ENDPOINT = '/traffic/{dev_eui}/monthly'; +const WATER_ENDPOINT = '/water/{dev_eui}'; +const DEVICE_PERMISSION_LEVEL_ENDPOINT = '/devices/{dev_eui}/permission-level'; +const DEVICE_LIST_PAGE_SIZE = 1000; + +function toIsoIfDate(value: string | Date | undefined): string | undefined { + if (value === undefined) return undefined; + return value instanceof Date ? value.toISOString() : value; +} + +function buildQueryString(query?: ApiQuery): string { + if (!query) return ''; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + searchParams.set(key, value instanceof Date ? value.toISOString() : String(value)); + } + + const serialized = searchParams.toString(); + return serialized.length > 0 ? `?${serialized}` : ''; +} + +function buildUrl(baseUrl: string, path: string, query?: ApiQuery): string { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ''); + const normalizedPath = path.length === 0 ? '' : path.startsWith('/') ? path : `/${path}`; + return `${normalizedBaseUrl}${normalizedPath}${buildQueryString(query)}`; +} + +function replacePathParams( + pathTemplate: string, + pathParams: Record +): string { + let resolvedPath = pathTemplate; + for (const [param, value] of Object.entries(pathParams)) { + resolvedPath = resolvedPath.replaceAll(`{${param}}`, encodeURIComponent(String(value))); + } + return resolvedPath; +} + +async function parseResponsePayload(response: Response): Promise { + const rawPayload = await response.text(); + if (!rawPayload) return null; + + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + try { + return JSON.parse(rawPayload) as unknown; + } catch { + return rawPayload; + } + } + + return rawPayload; +} + +function buildLoginRedirectPath(): string { + if (typeof location === 'undefined') { + return buildLoginPath({ reason: 'auth-required' }); + } + + const redirectTarget = `${location.pathname}${location.search}`; + if (!redirectTarget) { + return buildLoginPath({ reason: 'auth-required' }); + } + + return buildLoginPath({ + redirectTo: redirectTarget, + reason: 'auth-required' + }); +} + +function isCreatedAtKey(key: string): boolean { + return key.toLowerCase() === CREATED_AT_KEY; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function extractListPayload(payload: unknown): T[] | null { + if (Array.isArray(payload)) { + return payload as T[]; + } + + if (!isRecord(payload)) { + return null; + } + + const candidates = [ + payload.data, + payload.result, + payload.items, + isRecord(payload.data) ? payload.data.data : undefined, + isRecord(payload.data) ? payload.data.items : undefined, + isRecord(payload.result) ? payload.result.data : undefined, + isRecord(payload.result) ? payload.result.items : undefined + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate as T[]; + } + } + + return null; +} + +function resolvePaginatedTotal(payload: unknown, fallback: number): number { + if (!isRecord(payload)) { + return fallback; + } + + const candidates = [ + payload.total, + payload.count, + payload.totalItems, + isRecord(payload.data) ? payload.data.total : undefined, + isRecord(payload.data) ? payload.data.count : undefined, + isRecord(payload.result) ? payload.result.total : undefined, + isRecord(payload.result) ? payload.result.count : undefined + ]; + + for (const candidate of candidates) { + if (isFiniteNumber(candidate)) { + return Math.max(0, candidate); + } + } + + return fallback; +} + +function normalizePaginatedListResponse(payload: unknown): PaginatedResponse { + const items = extractListPayload(payload) ?? []; + return { + data: items, + total: resolvePaginatedTotal(payload, items.length) + }; +} + +function padDatePart(value: number, size = 2): string { + return String(value).padStart(size, '0'); +} + +function normalizeTimestampInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + if (TIMEZONE_SUFFIX_PATTERN.test(trimmed)) return trimmed; + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return `${trimmed}T00:00:00.000Z`; + } + + const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T'); + return `${normalized}Z`; +} + +function parseUtcTimestamp(value: string): Date | null { + const parsed = new Date(normalizeTimestampInput(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatOffset(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? '+' : '-'; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteMinutes / MINUTES_PER_HOUR); + const minutes = absoluteMinutes % MINUTES_PER_HOUR; + return `${sign}${padDatePart(hours)}:${padDatePart(minutes)}`; +} + +function formatTimestampForOffset(date: Date, offsetHours: number): string { + const offsetMinutes = Math.round(offsetHours * MINUTES_PER_HOUR); + const shiftedDate = new Date(date.getTime() + offsetMinutes * MILLISECONDS_PER_MINUTE); + + return [ + `${shiftedDate.getUTCFullYear()}-${padDatePart(shiftedDate.getUTCMonth() + 1)}-${padDatePart(shiftedDate.getUTCDate())}`, + `${padDatePart(shiftedDate.getUTCHours())}:${padDatePart(shiftedDate.getUTCMinutes())}:${padDatePart(shiftedDate.getUTCSeconds())}.${padDatePart(shiftedDate.getUTCMilliseconds(), 3)}${formatOffset(offsetMinutes)}` + ].join('T'); +} + +function toTimeZoneOffsetTimestamp(value: string, offsetHours: number): string { + const parsed = parseUtcTimestamp(value); + return parsed ? formatTimestampForOffset(parsed, offsetHours) : value; +} + +function toUtcTimestamp(value: string): string { + const parsed = parseUtcTimestamp(value); + return parsed ? parsed.toISOString() : value; +} + +function mapCreatedAtFields(value: unknown, mapper: (createdAt: string) => string): unknown { + if (Array.isArray(value)) { + return value.map((entry) => mapCreatedAtFields(entry, mapper)); + } + + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => { + if (isCreatedAtKey(key)) { + if (entryValue instanceof Date) { + return [key, mapper(entryValue.toISOString())]; + } + return [key, typeof entryValue === 'string' ? mapper(entryValue) : entryValue]; + } + + return [key, mapCreatedAtFields(entryValue, mapper)]; + }) + ); +} + +export class ApiService { + private readonly baseUrl: string; + private readonly fetchFn: FetchLike; + private authToken: string | null; + private timeZoneOffset: number; + + public constructor(options: ApiServiceOptions = {}) { + this.baseUrl = options.baseUrl ?? API_BASE_URL; + this.fetchFn = options.fetchFn ?? fetch; + this.authToken = options.authToken ?? null; + const configuredTimeZoneOffset = options.timeZoneOffset; + this.timeZoneOffset = + typeof configuredTimeZoneOffset === 'number' && Number.isFinite(configuredTimeZoneOffset) + ? configuredTimeZoneOffset + : 0; + } + + public setAuthToken(token: string | null): void { + this.authToken = token; + } + + public setTimeZoneOffset(offset: number): void { + if (!Number.isFinite(offset)) { + throw new TypeError('timeZoneOffset must be a finite number'); + } + + this.timeZoneOffset = offset; + } + + public clearAuthToken(): void { + this.authToken = null; + } + + private async request(path: string, options: ApiRequestOptions = {}): Promise { + const { + body, + headers, + query, + authToken, + responseType = 'json', + suppressErrorStatuses, + ...requestInit + } = options; + const url = buildUrl(this.baseUrl, path, query); + + const resolvedHeaders = new Headers(headers); + const resolvedToken = authToken === undefined ? this.authToken : authToken; + + if (!resolvedHeaders.has('Accept')) { + resolvedHeaders.set( + 'Accept', + responseType === 'json' ? 'application/json' : 'text/plain, text/html, */*' + ); + } + + let serializedBody: string | undefined; + if (body !== undefined) { + if (!resolvedHeaders.has('Content-Type')) { + resolvedHeaders.set('Content-Type', 'application/json'); + } + serializedBody = JSON.stringify( + mapCreatedAtFields(body, (createdAt) => toUtcTimestamp(createdAt)) + ); + } + + if (resolvedToken) { + resolvedHeaders.set('Authorization', `Bearer ${resolvedToken}`); + } + + const response = await this.fetchFn(url, { + ...requestInit, + headers: resolvedHeaders, + body: serializedBody + }); + + const payload = await parseResponsePayload(response); + if (!response.ok) { + if (!(suppressErrorStatuses ?? []).includes(response.status)) { + console.error('API request failed', { + url, + method: requestInit.method ?? 'GET', + status: response.status, + statusText: response.statusText, + payload + }); + } + if (response.status === 401) { + this.clearAuthToken(); + if (typeof window !== 'undefined' && typeof window.location !== 'undefined') { + const loginRedirectPath = buildLoginRedirectPath(); + window.location.href = loginRedirectPath; + } + } + + throw new ApiServiceError(response.status, response.statusText, { url, payload }); + } + + if (responseType === 'text') { + return (typeof payload === 'string' ? payload : JSON.stringify(payload ?? '')) as T; + } + + return mapCreatedAtFields(payload, (createdAt) => + toTimeZoneOffsetTimestamp(createdAt, this.timeZoneOffset) + ) as T; + } + + public getApiHome(): Promise { + return this.request('', { method: 'GET', responseType: 'text' }); + } + + public getAuthenticatedUser(): Promise> { + return this.request>(AUTH_ENDPOINT, { method: 'GET' }); + } + + public getUserProfile(): Promise> { + return this.request>(AUTH_USER_PROFILE_ENDPOINT, { method: 'GET' }); + } + + public login(payload: LoginRequest): Promise { + return this.request(LOGIN_ENDPOINT, { + method: 'POST', + body: payload, + authToken: null + }); + } + + public getAirData(devEui: string, query: TimeRangeQuery = {}): Promise { + return this.request( + replacePathParams(AIR_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + start: toIsoIfDate(query.start), + end: toIsoIfDate(query.end), + timezone: query.timezone + } + } + ); + } + + public getSoilData(devEui: string, query: TimeRangeQuery = {}): Promise { + return this.request( + replacePathParams(SOIL_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + start: toIsoIfDate(query.start), + end: toIsoIfDate(query.end), + timezone: query.timezone + } + } + ); + } + + public getWaterData(devEui: string, query: TimeRangeQuery = {}): Promise { + return this.request(replacePathParams(WATER_ENDPOINT, { dev_eui: devEui }), { + method: 'GET', + query: { + start: toIsoIfDate(query.start), + end: toIsoIfDate(query.end), + timezone: query.timezone + } + }); + } + + public getPowerRecord(id: string): Promise { + return this.request(replacePathParams(POWER_ENDPOINT, { id }), { + method: 'GET' + }); + } + + public getRelayData( + devEui: string, + options: GetRelayDataOptions = {} + ): Promise> { + return this.request>( + replacePathParams(RELAY_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + signal: options.signal, + suppressErrorStatuses: options.suppressNotFoundError ? [404] : undefined + } + ); + } + + public updateRelay( + devEui: string, + payload: UpdateRelayRequest + ): Promise | null> { + return this.request | null>( + replacePathParams(RELAY_ENDPOINT, { dev_eui: devEui }), + { + method: 'PATCH', + body: payload + } + ); + } + + public getTrafficData(devEui: string, query: TimeRangeQuery = {}): Promise { + return this.request( + replacePathParams(TRAFFIC_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + start: toIsoIfDate(query.start), + end: toIsoIfDate(query.end), + timezone: query.timezone + } + } + ); + } + + public getTrafficMonthlyReport( + devEui: string, + query: TrafficMonthlyQuery + ): Promise { + return this.request( + replacePathParams(TRAFFIC_MONTHLY_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + year: query.year, + month: query.month, + timezone: query.timezone + } + } + ); + } + + public pulseRelay( + devEui: string, + payload: PulseRelayRequest + ): Promise | null> { + return this.request | null>( + replacePathParams(RELAY_PULSE_ENDPOINT, { dev_eui: devEui }), + { + method: 'POST', + body: payload + } + ); + } + + public async getDevicesPage(query: DeviceListQuery = {}): Promise> { + const payload = await this.request< + DeviceDto[] | PaginatedResponse | Record + >(DEVICES_ENDPOINT, { + method: 'GET', + query: { + skip: query.skip, + take: query.take, + dev_eui: query.dev_eui, + group: query.group, + name: query.name, + location: query.location + } + }); + + return normalizePaginatedListResponse(payload); + } + + public async getDevices(query: DeviceListQuery = {}): Promise { + const { data } = await this.getDevicesPage(query); + return data ?? []; + } + + public async getAllDevices( + query: Omit = {} + ): Promise { + const firstPage = await this.getDevicesPage({ + ...query, + skip: 0, + take: DEVICE_LIST_PAGE_SIZE + }); + const firstBatch = firstPage.data ?? []; + const total = + typeof firstPage.total === 'number' && Number.isFinite(firstPage.total) + ? Math.max(0, firstPage.total) + : firstBatch.length; + + if (firstBatch.length >= total) { + return firstBatch; + } + + const allDevices = [...firstBatch]; + + for (let skip = DEVICE_LIST_PAGE_SIZE; skip < total; skip += DEVICE_LIST_PAGE_SIZE) { + const nextPage = await this.getDevicesPage({ + ...query, + skip, + take: DEVICE_LIST_PAGE_SIZE + }); + const nextBatch = nextPage.data ?? []; + + if (nextBatch.length === 0) { + break; + } + + allDevices.push(...nextBatch); + } + + return allDevices; + } + + public async getAllLocationDevices( + locationId: number + ): Promise> { + const endpoint = `${DEVICES_ENDPOINT}/location/${locationId}`; + const payload = await this.request< + DevicePrimaryDataDto[] | PaginatedResponse + >(endpoint, { + method: 'GET' + }); + + return normalizePaginatedListResponse(payload); + } + + public async getAllLocations( + query: LocationsQuery = {} + ): Promise> { + const payload = await this.request>( + LOCATIONS_ENDPOINT, + { + method: 'GET', + query: { + name: query.name + } + } + ); + + return normalizePaginatedListResponse(payload); + } + + public async getLocations(): Promise { + const { data } = await this.getAllLocations(); + return data ?? []; + } + + public getLocation(id: number | string): Promise { + return this.request(replacePathParams(LOCATION_BY_ID_ENDPOINT, { id }), { + method: 'GET' + }); + } + + public getLocationGroups(): Promise { + return this.request(`${LOCATIONS_ENDPOINT}/groups`, { + method: 'GET' + }); + } + + public createLocationPermission( + location_id: number | string, + newUserEmail: string, + permission_level: number, + applyToAllDevices?: boolean + ): Promise { + return this.request( + replacePathParams(LOCATION_PERMISSION_ENDPOINT, { id: location_id }), + { + method: 'POST', + query: { + newUserEmail, + permission_level, + applyToAllDevices + }, + body: { + location_id, + user_email: newUserEmail, + applyToAllDevices + } + } + ); + } + + public updateLocationPermission( + location_id: number | string, + payload: UpdateLocationOwnerRequest, + applyToAllDevices?: boolean + ): Promise { + return this.request( + replacePathParams(LOCATION_PERMISSION_ENDPOINT, { id: location_id }), + { + method: 'PATCH', + query: { + applyToAllDevices + }, + body: payload + } + ); + } + + public async updateLocationPermissionLevel( + location_id: number | string, + email: string, + permission_level: number + ): Promise { + const endpoint = replacePathParams(LOCATION_PERMISSION_UPDATE_PERMISSION_LEVEL_ENDPOINT, { + id: location_id + }); + return this.request(endpoint, { + method: 'PATCH', + body: { + location_id, + email, + permission_level + } + }); + } + + public createLocation( + payload: CreateLocationRequest + ): Promise> { + return this.request>(LOCATIONS_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public updateLocation( + id: number | string, + payload: UpdateLocationRequest + ): Promise> { + return this.request>( + replacePathParams(LOCATION_BY_ID_ENDPOINT, { id }), + { + method: 'PATCH', + body: payload + } + ); + } + + public deleteLocation(id: number | string): Promise> { + return this.request>( + replacePathParams(LOCATION_BY_ID_ENDPOINT, { id }), + { + method: 'DELETE' + } + ); + } + + public deleteLocationPermission( + location_id: number | string, + permissionId?: number | string + ): Promise { + const endpoint = replacePathParams(LOCATION_PERMISSION_ENDPOINT, { id: location_id }); + return this.request(endpoint, { + method: 'DELETE', + query: { + permission_id: permissionId + } + }); + } + + public getDeviceStatuses(): Promise { + return this.request(DEVICE_STATUS_ENDPOINT, { + method: 'GET' + }); + } + + public getDeviceGroups(): Promise { + return this.request(`${DEVICES_ENDPOINT}/groups`, { + method: 'GET' + }); + } + + public async getDeviceTypes(): Promise { + const payload = await this.request< + DeviceTypeDto[] | PaginatedResponse | Record + >(DEVICE_TYPES_ENDPOINT, { + method: 'GET' + }); + + return normalizePaginatedListResponse(payload).data ?? []; + } + + public getLatestPrimaryDeviceData( + query: LatestPrimaryDataQuery = {}, + options: ApiMethodOptions = {} + ): Promise> { + return this.request>( + DEVICE_LATEST_PRIMARY_ENDPOINT, + { + method: 'GET', + signal: options.signal, + query: { + skip: query.skip, + take: query.take, + dev_eui: query.dev_eui, + 'group-by-device-group': query.group, + locationGroup: query.locationGroup, + location: query.location, + name: query.name + } + } + ).then((payload) => normalizePaginatedListResponse(payload)); + } + + public getDevice(devEui: string): Promise { + return this.request( + replacePathParams(DEVICE_BY_DEV_EUI_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET' + } + ); + } + + public createDevice(devEui: string, payload: CreateDeviceRequest): Promise { + return this.request( + replacePathParams(DEVICE_BY_DEV_EUI_ENDPOINT, { dev_eui: devEui }), + { + method: 'POST', + body: payload + } + ); + } + + public getDeviceData( + devEui: string, + query: PaginationQuery = {} + ): Promise>> { + return this.request>>( + replacePathParams(DEVICE_DATA_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + skip: query.skip, + take: query.take + } + } + ); + } + + public getDeviceDataWithinRange( + devEui: string, + query: DeviceDataWithinRangeQuery = {} + ): Promise>> { + return this.request>>( + replacePathParams(DEVICE_DATA_WITHIN_RANGE_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + query: { + skip: query.skip, + take: query.take, + start: toIsoIfDate(query.start), + end: toIsoIfDate(query.end), + timezone: query.timezone + } + } + ); + } + + public getDeviceLatestData( + devEui: string, + options: GetDeviceLatestDataOptions = {} + ): Promise> { + return this.request>( + replacePathParams(DEVICE_LATEST_DATA_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + signal: options.signal, + suppressErrorStatuses: options.suppressNotFoundError ? [404] : undefined + } + ); + } + + public getDeviceLatestPrimaryData( + devEui: string, + options: ApiMethodOptions = {} + ): Promise { + return this.request( + replacePathParams(DEVICE_LATEST_PRIMARY_BY_DEV_EUI_ENDPOINT, { + dev_eui: devEui + }), + { + method: 'GET', + signal: options.signal + } + ); + } + + public createAirNote(payload: { + note: string; + created_at: string; + dev_eui: string; + }): Promise | null> { + return this.request | null>(AIR_NOTES_CREATE_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public updateDevice( + devEui: string, + payload: Record + ): Promise | null> { + return this.request | null>( + replacePathParams(DEVICE_BY_DEV_EUI_ENDPOINT, { dev_eui: devEui }), + { + method: 'PATCH', + body: payload + } + ); + } + + public updateDevicePermissionLevel( + devEui: string, + payload: { + targetUserEmail: string; + permissionLevel: number; + } + ): Promise | null> { + return this.request | null>( + replacePathParams(DEVICE_PERMISSION_LEVEL_ENDPOINT, { dev_eui: devEui }), + { + method: 'PATCH', + body: { ...payload, dev_eui: devEui } + } + ); + } + + public createRule(payload: CreateRuleRequest): Promise { + return this.request(RULES_BASE_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public getRules(query: RulesQuery = {}): Promise { + return this.request(RULES_BASE_ENDPOINT, { + method: 'GET', + query: { + name: query.name + } + }); + } + + public getTriggeredRules(): Promise { + return this.request(TRIGGERED_RULES_BASE_ENDPOINT, { + method: 'GET' + }); + } + + public getTriggeredRulesCount(): Promise { + return this.request(TRIGGERED_RULES_COUNT_ENDPOINT, { + method: 'GET' + }); + } + + public getRule(id: number): Promise { + return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id }), { + method: 'GET' + }); + } + + public updateRule(ruleGroupId: string, payload: UpdateRuleRequest): Promise { + return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id: ruleGroupId }), { + method: 'PATCH', + body: payload + }); + } + + public deleteRule(ruleGroupId: string): Promise { + return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id: ruleGroupId }), { + method: 'DELETE' + }); + } + + public createReport(payload: CreateReportRequest): Promise { + return this.request(REPORTS_BASE_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public getReports(query: ReportsQuery = {}): Promise { + return this.request(REPORTS_BASE_ENDPOINT, { + method: 'GET', + query: { + name: query.name + } + }); + } + + public getReportHistory(devEui: string): Promise { + return this.request( + replacePathParams(REPORT_HISTORY_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET' + } + ); + } + + public getReportDownloadUrl( + dev_eui: string, + report_id: string, + reportName: string + ): Promise> { + return this.request>( + replacePathParams(REPORT_DOWNLOAD_ENDPOINT, { dev_eui, report_id, reportName }), + { + method: 'GET' + } + ); + } + + public getReport(reportId: string): Promise { + return this.request( + replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), + { + method: 'GET' + } + ); + } + + public updateReport(reportId: string, payload: UpdateReportRequest): Promise { + return this.request( + replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), + { + method: 'PATCH', + body: payload + } + ); + } + + public deleteReport(reportId: string): Promise { + return this.request( + replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), + { + method: 'DELETE' + } + ); + } + + public getPaymentsProducts(): Promise { + return this.request(PAYMENTS_PRODUCTS_ENDPOINT, { method: 'GET' }); + } + + public getPaymentsSubscriptions(): Promise { + return this.request(PAYMENTS_SUBSCRIPTIONS_ENDPOINT, { method: 'GET' }); + } + + public getPaymentsSubscriptionState(): Promise { + return this.request(PAYMENTS_SUBSCRIPTION_STATE_ENDPOINT, { method: 'GET' }); + } + + public createPaymentsCheckoutSession(payload: Record): Promise { + return this.request(PAYMENTS_SUBSCRIPTIONS_CHECKOUT_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public createPaymentsPortalSession(payload: Record): Promise { + return this.request(PAYMENTS_SUBSCRIPTIONS_PORTAL_ENDPOINT, { + method: 'POST', + body: payload + }); + } + + public cancelPaymentsSubscription(subscriptionId: string): Promise { + return this.request( + `${PAYMENTS_SUBSCRIPTIONS_ENDPOINT}/${encodeURIComponent(subscriptionId)}`, + { + method: 'DELETE' + } + ); + } +} + +export * from './api.dtos'; + +export const apiService = new ApiService(); + +export default apiService; diff --git a/src/lib/appContext.svelte.ts b/src/lib/appContext.svelte.ts new file mode 100644 index 00000000..beb1e0ec --- /dev/null +++ b/src/lib/appContext.svelte.ts @@ -0,0 +1,113 @@ +// appContext.ts +import { getContext, setContext } from 'svelte'; +import type { IJWT } from './interfaces/jwt.interface'; +import type { IDevice } from './interfaces/device.interface'; +import type { IRule } from './interfaces/rule.interface'; +import type { Profile } from './interfaces/profile.interface'; +import { createCwAlarmScheduler } from '@cropwatchdevelopment/cwui'; +import type { RuleDto, TriggeredRulesCountResponse } from './api/api.service'; +import type { LocationDto } from './api/api.dtos'; +import type { DeviceTypeLookup } from './components/dashboard/dashboard-device-data'; + +const DEVICE_STALE_MINUTES = 10; +const deviceAlarms = createCwAlarmScheduler(); + +const app = createAppContext(); + +export interface AppContext { + profile?: Profile; + session: IJWT | null; + devices: IDevice[]; + deviceGroups?: string[]; + locationGroups?: string[]; + locations?: LocationDto[]; + deviceTypeLookup: DeviceTypeLookup; + deviceStatuses: { online: number; offline: number }; + totalDeviceCount?: number; + rules: IRule[]; + triggeredRules: RuleDto[]; + triggeredRulesCount: number; + staleDeviceIds: string[]; + accessToken?: string; + drawerOpen?: boolean; + privacyModeEnabled?: boolean; +} + +export const appContextKey = Symbol('appContext'); + +export const defaultAppContext: AppContext = { + profile: undefined, + session: null, + devices: [], + deviceTypeLookup: { byModel: {}, idToModel: {} }, + deviceStatuses: { online: 0, offline: 0 }, + totalDeviceCount: 0, + rules: [], + triggeredRules: [], + triggeredRulesCount: 0, + staleDeviceIds: [], + locationGroups: [], + accessToken: undefined, + drawerOpen: false, + privacyModeEnabled: false +}; + +export function createAppContext(initial: Partial = {}): AppContext { + + // Check if context already exists to avoid overwriting it (e.g. during hot reload) + try { + const existing = getContext(appContextKey); + if (existing) { + return existing; + } else { + return { + profile: undefined, + session: null, + devices: [], + deviceTypeLookup: { byModel: {}, idToModel: {} }, + deviceStatuses: { online: 0, offline: 0 }, + totalDeviceCount: 0, + rules: [], + triggeredRules: [], + triggeredRulesCount: 0, + accessToken: undefined, + staleDeviceIds: [], + locationGroups: [], + drawerOpen: false, + privacyModeEnabled: false, + ...initial + }; + } + } catch { + // No existing context, will create a new one + return { + profile: undefined, + session: null, + devices: [], + deviceTypeLookup: { byModel: {}, idToModel: {} }, + deviceStatuses: { online: 0, offline: 0 }, + totalDeviceCount: 0, + rules: [], + triggeredRules: [], + triggeredRulesCount: 0, + accessToken: undefined, + staleDeviceIds: [], + locationGroups: [], + drawerOpen: false, + privacyModeEnabled: false, + ...initial + }; + } +} + +export function setAppContext(context: AppContext) { + setContext(appContextKey, context); +} + +export function getAppContext(): AppContext { + return getContext(appContextKey); +} + +export function updateAppContext(updates: Partial) { + Object.assign(app, updates); +} diff --git a/src/lib/auth/auth-recaptcha.svelte.ts b/src/lib/auth/auth-recaptcha.svelte.ts new file mode 100644 index 00000000..350a3b26 --- /dev/null +++ b/src/lib/auth/auth-recaptcha.svelte.ts @@ -0,0 +1,67 @@ +import { + getRecaptchaStatus, + resetRecaptchaFailureState, + runRecaptchaAction, + type RecaptchaAction, + type RecaptchaStatus, + warmupRecaptcha +} from '$lib/utils/recaptcha'; + +type AuthRecaptchaState = { + status: RecaptchaStatus; + lastError: string | null; +}; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + return 'reCAPTCHA failed'; +} + +export function createAuthRecaptcha() { + const state = $state({ + status: getRecaptchaStatus(), + lastError: null + }); + + function syncStatus() { + state.status = getRecaptchaStatus(); + } + + async function warmup(): Promise { + syncStatus(); + const warmed = await warmupRecaptcha(); + syncStatus(); + return warmed; + } + + async function runAction(action: RecaptchaAction): Promise { + state.lastError = null; + syncStatus(); + + try { + const token = await runRecaptchaAction(action); + syncStatus(); + return token; + } catch (error) { + state.lastError = toErrorMessage(error); + resetFailureState(); + throw error; + } + } + + function resetFailureState() { + resetRecaptchaFailureState(); + state.lastError = null; + syncStatus(); + } + + return { + state, + warmup, + runAction, + resetFailureState + }; +} diff --git a/src/lib/components/BatteryLevel.svelte b/src/lib/components/BatteryLevel.svelte deleted file mode 100644 index 35bccdf9..00000000 --- a/src/lib/components/BatteryLevel.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - -
    - - - - - {value}% - - -
    -

    Battery Level

    -

    - The current battery level of the device is at {value}%. -

    -
    -
    -
    -
    -
    diff --git a/src/lib/components/CopyButton.svelte b/src/lib/components/CopyButton.svelte deleted file mode 100644 index 9ad15107..00000000 --- a/src/lib/components/CopyButton.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - diff --git a/src/lib/components/DataCard/Data-Card.css b/src/lib/components/DataCard/Data-Card.css deleted file mode 100644 index 3a44e263..00000000 --- a/src/lib/components/DataCard/Data-Card.css +++ /dev/null @@ -1,36 +0,0 @@ -.sensor-readings .readings-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.reading-card { - padding: 1rem; - border-radius: 8px; - text-align: center; - background-color: var(--color-card, #ffffff); - transition: transform 0.2s, box-shadow 0.2s; - border-left: 4px solid var(--card-color, #cccccc); - box-shadow: var(--shadow-sm); -} - -.reading-card:hover { - transform: translateY(-5px); - box-shadow: var(--shadow-md); -} - -.reading-card h3 { - font-size: 1rem; - margin: 0 0 0.5rem 0; - color: var(--color-text-secondary); -} - -.reading-card .value { - font-size: 1.8rem; - font-weight: bold; - margin: 0; - color: var(--card-color, var(--color-text)); -} - -/* Each sensor type will automatically get its border color from the CSS variable */ \ No newline at end of file diff --git a/src/lib/components/DataCard/DataCard.svelte b/src/lib/components/DataCard/DataCard.svelte deleted file mode 100644 index 12ec9c99..00000000 --- a/src/lib/components/DataCard/DataCard.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -{#if hasValue(latestData, key)} - {@const value = latestData[key]} - -{/if} diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte deleted file mode 100644 index 76a9fa79..00000000 --- a/src/lib/components/DataTable.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -
    -
    -
    - -
    -

    {$_('Sensor Data - Today')}

    -
    - - -
    - {#if filteredData.length === 0} -
    -

    {$_('No data available for today')}

    -
    - {:else} -
    - - - {#each visibleColumns as column} - - {/each} - - - - - {#each filteredData as row, rowIndex} - - {#each visibleColumns as column} - - {/each} - - {/each} - -
    - {$_(column)} -
    - {formatCellValue(column, row[column])} -
    - {/if} - - - - diff --git a/src/lib/components/GlobalLoading.svelte b/src/lib/components/GlobalLoading.svelte deleted file mode 100644 index 864ea2c8..00000000 --- a/src/lib/components/GlobalLoading.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - -{#if $globalLoading} -
    -
    - -

    Loading...

    -
    -
    -{/if} - - diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte deleted file mode 100644 index 5fd4c2bd..00000000 --- a/src/lib/components/GlobalSidebar.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - - -{#if isOpen} -
    sidebarStore.close()} - aria-hidden="true" - >
    -{/if} - - - - - diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte deleted file mode 100644 index 8df4614c..00000000 --- a/src/lib/components/Header.svelte +++ /dev/null @@ -1,264 +0,0 @@ - - - -
    - - - - {#if mobileMenuOpen} -
    -
    - - - - -
    - -
    - - Theme - - -
    - - -
    - - Language - - -
    - - - - - - - - Help - - - -
    - {#if page.data.session?.user} - - {:else} - - {/if} -
    -
    -
    -
    - {/if} -
    - - diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte new file mode 100644 index 00000000..d5f083e0 --- /dev/null +++ b/src/lib/components/Icon.svelte @@ -0,0 +1,62 @@ + + +{#if preserveColor} + +{:else} + +{/if} + + diff --git a/src/lib/components/LanguageSwitcher.svelte b/src/lib/components/LanguageSwitcher.svelte new file mode 100644 index 00000000..2e403cdc --- /dev/null +++ b/src/lib/components/LanguageSwitcher.svelte @@ -0,0 +1,82 @@ + + +
    + + +
    + + diff --git a/src/lib/components/RelayControl.svelte b/src/lib/components/RelayControl.svelte deleted file mode 100644 index eed88eae..00000000 --- a/src/lib/components/RelayControl.svelte +++ /dev/null @@ -1,359 +0,0 @@ - - -
    -

    {$_('Relay control')}

    -

    - {$_('Two big buttons for each relay. Tap once and wait for the light to change.')} -

    - - {#if cooldownRemaining > 0} -
    - {translate('Next action available in {seconds}s', { seconds: cooldownRemaining })} -
    - {/if} - - {#if loadingInitial} -
    - - {$_('Checking relay status…')} -
    - {/if} - -
    - {#each relays as relay} -
    -
    -

    {$_(relay.labelKey)}

    - {relayStatusText(relay.key)} -
    -
    - - -
    -

    - {#if busy[relay.key]} - {$_('Sending command…')} - {:else} - {relayStatusText(relay.key)} - {/if} -

    -
    - {/each} -
    -
    - - diff --git a/src/lib/components/Reports/NewPoint.svelte b/src/lib/components/Reports/NewPoint.svelte deleted file mode 100644 index b77f78fb..00000000 --- a/src/lib/components/Reports/NewPoint.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - - -{#if point.operator !== null} - -{/if} -{#if point.operator === null} - - -{/if} - - - diff --git a/src/lib/components/Reports/NumberLine.svelte b/src/lib/components/Reports/NumberLine.svelte deleted file mode 100644 index 8e0523d9..00000000 --- a/src/lib/components/Reports/NumberLine.svelte +++ /dev/null @@ -1,179 +0,0 @@ - - -
    -
    - - - - {@render children()} -
    - - -
    - - diff --git a/src/lib/components/SiteWideRefreshButton.svelte b/src/lib/components/SiteWideRefreshButton.svelte deleted file mode 100644 index c62ae83b..00000000 --- a/src/lib/components/SiteWideRefreshButton.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -{#if displaySafariRefresh} - -{/if} diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte deleted file mode 100644 index 1eee9421..00000000 --- a/src/lib/components/Spinner.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - diff --git a/src/lib/components/StatsCard/StatsCard.css b/src/lib/components/StatsCard/StatsCard.css deleted file mode 100644 index 6c3a9fb7..00000000 --- a/src/lib/components/StatsCard/StatsCard.css +++ /dev/null @@ -1,31 +0,0 @@ -.stat-card { - padding: 1rem; - background-color: var(--color-card, #ffffff); - border-radius: 6px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.stat-card h4 { - margin-top: 0; - margin-bottom: 0.5rem; - font-size: 1rem; -} - -.stat-values { - display: flex; - justify-content: space-between; -} - -.stat-item { - text-align: center; -} - -.stat-label { - font-size: 0.8rem; - display: block; -} - -.stat-value { - font-weight: bold; - font-size: 1.1rem; -} \ No newline at end of file diff --git a/src/lib/components/StatsCard/StatsCard.svelte b/src/lib/components/StatsCard/StatsCard.svelte deleted file mode 100644 index a8590278..00000000 --- a/src/lib/components/StatsCard/StatsCard.svelte +++ /dev/null @@ -1,189 +0,0 @@ - - -
    e.key === 'Enter' && toggleExpand()} -> - -
    -

    - {title} -

    - - {#if lastReading !== undefined && trend !== undefined} -
    - - {formatNumber({ key, value: lastReading })} - {#if notation} - {notation} - {/if} - - - - -
    - {/if} -
    - - -
    - {$_('Min')} - {$_('Avg')} - {$_('Max')} -
    - - -
    - - {formatNumber({ key, value: min })} - {#if notation} - {notation} - {/if} - - - {formatNumber({ key, value: avg })} - {#if notation} - {notation} - {/if} - - - {formatNumber({ key, value: max })} - {#if notation} - {notation} - {/if} - -
    - - -
    - -
    -
    - - -
    - - - {#if medianPercent !== null} -
    - {/if} -
    - - - {#if expanded} -
    -
    -
    - {$_('Count')}: - {count !== undefined ? count : $_('N/A')} -
    - -
    - {$_('Median')}: - - {median !== undefined ? formatNumber({ key, value: median }) + (notation || '') : 'N/A'} - -
    - -
    - {$_('Std Dev')}: - - {stdDev !== undefined ? formatNumber({ key, value: stdDev }) + (notation || '') : 'N/A'} - -
    - -
    - {$_('Range')}: - - {max !== undefined && min !== undefined - ? formatNumber({ key, value: max - min }) + (notation || '') - : $_('N/A')} - -
    -
    -
    - {/if} - - {#if expandable} -
    -
    - - {expanded ? $_('Click to collapse') : $_('Click to expand')} -
    -
    - {/if} -
    diff --git a/src/lib/components/Toast/Toast.svelte b/src/lib/components/Toast/Toast.svelte deleted file mode 100644 index 97c1e071..00000000 --- a/src/lib/components/Toast/Toast.svelte +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/src/lib/components/Toast/ToastContainer.svelte b/src/lib/components/Toast/ToastContainer.svelte deleted file mode 100644 index 68b998db..00000000 --- a/src/lib/components/Toast/ToastContainer.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - -
    0} -> - {#each toasts as toast (toast.id)} -
    - -
    - {/each} -
    - - \ No newline at end of file diff --git a/src/lib/components/UI/buttons/Button.svelte b/src/lib/components/UI/buttons/Button.svelte deleted file mode 100644 index 104013ba..00000000 --- a/src/lib/components/UI/buttons/Button.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -) => onclick?.(e.detail)} -> - {@render children?.()} - diff --git a/src/lib/components/UI/dashboard/AllDevices.svelte b/src/lib/components/UI/dashboard/AllDevices.svelte deleted file mode 100644 index 07c3fe26..00000000 --- a/src/lib/components/UI/dashboard/AllDevices.svelte +++ /dev/null @@ -1,238 +0,0 @@ - - -
    - {#if filteredLocations.length === 0} -
    -

    No locations match your search criteria.

    -
    - {:else} - {#each filteredLocations as location (location.location_id)} - {@const hasNullStatus = (location.cw_devices ?? []).some( - (d: DeviceWithSensorData) => deviceActiveStatus[d.dev_eui] === null - )} - {@const activeDevices = (location.cw_devices ?? []).filter((d: DeviceWithSensorData) => - isDeviceActive(d, deviceActiveStatus) - )} - {@const allActive = - (location.cw_devices?.length ?? 0) > 0 && - activeDevices.length === (location.cw_devices?.length ?? 0)} - {@const allInactive = (location.cw_devices?.length ?? 0) > 0 && activeDevices.length === 0} - - {#snippet content()} - {@const locationDevices = sortDevicesByName(location.cw_devices ?? [])} - {@const dragHandlers = createDragHandlers( - locationDevices, - (newDevices) => { - // Save the new order to localStorage - const deviceOrder = newDevices.map((device) => device.dev_eui); - saveDeviceOrder(location.location_id, deviceOrder); - - if (onDeviceReorder) { - onDeviceReorder(location.location_id, newDevices); - } - }, - getDragState(location.location_id), - (newState) => updateDragState(location.location_id, newState) - )} - {#each locationDevices as device, index (device.dev_eui)} - {@const isActive = isDeviceActive(device, deviceActiveStatus)} - {@const formattedDevice = { - ...device, - latestData: device.latestData || {}, - cw_device_type: { - name: device.cw_device_type?.name || 'Unknown', - default_upload_interval: device.cw_device_type?.default_upload_interval || 10, - primary_data_notation: device.cw_device_type?.primary_data_notation || '', - secondary_data_notation: - device.cw_device_type?.secondary_data_notation || undefined, - primary_data_v2: device.cw_device_type?.primary_data_v2 || undefined, - secondary_data_v2: device.cw_device_type?.secondary_data_v2 || undefined - } - }} - - {/each} - {/snippet} - - {/each} - {/if} -
    - - diff --git a/src/lib/components/UI/dashboard/DashboardCard.svelte b/src/lib/components/UI/dashboard/DashboardCard.svelte deleted file mode 100644 index 1c4cfbab..00000000 --- a/src/lib/components/UI/dashboard/DashboardCard.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
    -
    -
    - - {#if loading} - - {:else if allActive} - - {:else if activeDevices.length > 0 && !allInactive} - - {:else} - - {/if} - -

    - {location.name} -

    -
    - -
    - -
    - {#if content} - {@render content()} - {/if} -
    -
    diff --git a/src/lib/components/UI/dashboard/DashboardFilter.svelte b/src/lib/components/UI/dashboard/DashboardFilter.svelte deleted file mode 100644 index 2b4ddba0..00000000 --- a/src/lib/components/UI/dashboard/DashboardFilter.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - - - diff --git a/src/lib/components/UI/dashboard/DashboardFilterBits.svelte b/src/lib/components/UI/dashboard/DashboardFilterBits.svelte deleted file mode 100644 index 53e45f8c..00000000 --- a/src/lib/components/UI/dashboard/DashboardFilterBits.svelte +++ /dev/null @@ -1,490 +0,0 @@ - - -
    - - - - - - - - - - - - - -
    - - - -
    - -
    -
    - - {$_('Hide/Show Empty')} -
    -
    -
    - - - -
    -
    - - - - - - - -
    - - -
    - - - -
    -
    - - setDashboardViewType('grid', e)} - > -
    - - - - Grid -
    - {#if dashboardViewType === 'grid'} - - - - {/if} -
    - setDashboardViewType('mozaic', e)} - > -
    - - - - Mozaic -
    - {#if dashboardViewType === 'mozaic'} - - - - {/if} -
    - setDashboardViewType('list', e)} - > -
    - - - - List -
    - {#if dashboardViewType === 'list'} - - - - {/if} -
    -
    -
    - {$_('Dashboard Style')} -
    -
    - -
    Dashboard Layout
    -
    -
    -
    - - - - - - - -
    - - -
    - - - -
    -
    - - setDashboardSortType('alpha', e)} - > -
    - - - - Alpha -
    - {#if dashboardSortType === 'alpha'} - - - - {/if} -
    - setDashboardSortType('date', e)} - > -
    - - - - Date -
    - {#if dashboardSortType === 'date'} - - - - {/if} -
    - setDashboardSortType('time', e)} - > -
    - - - - Time -
    - {#if dashboardSortType === 'time'} - - - - {/if} -
    -
    -
    - {$_('Sort By')} -
    -
    - -
    Sort By
    -
    -
    -
    -
    -
    -
    -
    - - diff --git a/src/lib/components/UI/dashboard/DataRowItem.svelte b/src/lib/components/UI/dashboard/DataRowItem.svelte deleted file mode 100644 index 31ab94bf..00000000 --- a/src/lib/components/UI/dashboard/DataRowItem.svelte +++ /dev/null @@ -1,241 +0,0 @@ - - -{#snippet triggerSnippet()} -
    { - if (dragEnabled && onDragOver && dragIndex !== undefined) { - e.preventDefault(); - onDragOver(e, dragIndex); - } - }} - ondrop={(e) => { - if (dragEnabled && onDrop && dragIndex !== undefined) { - e.preventDefault(); - onDrop(e, dragIndex); - } - }} - > -
    { - if (dragEnabled && onDragStart && dragIndex !== undefined && e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', device.dev_eui); - onDragStart(e, dragIndex); - } - }} - ondragend={(e) => { - if (dragEnabled && onDragEnd) { - onDragEnd(e); - } - }} - title={dragEnabled ? 'Drag to reorder' : ''} - >
    -
    -
    -
    - {device.name || `Device ${device.dev_eui}`} -
    -
    - {#if device.latestData} -
    - {nameToEmoji(primaryDataKey)} -
    - - {formatNumber({ key: primaryDataKey, value: primaryValue })} - {primaryNotation} - -
    -
    - {#if secondaryDataKey} - -
    - {nameToEmoji(secondaryDataKey)} -
    - - {formatNumber({ key: secondaryDataKey, value: secondaryValue })} - {secondaryNotation} - -
    -
    - {/if} - {/if} -
    -
    -
    -
    - -
    -
    -{/snippet} - -{#snippet collapseChildren()} -
    - {#if children} - {@render children()} - {/if} - {#if detailHref || location} - - {/if} -
    -{/snippet} - - collapseStateChange(e)} - trigger={triggerSnippet} - children={collapseChildren} -/> diff --git a/src/lib/components/UI/dashboard/DeviceCard.svelte b/src/lib/components/UI/dashboard/DeviceCard.svelte deleted file mode 100644 index 38e5bb8d..00000000 --- a/src/lib/components/UI/dashboard/DeviceCard.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - - - {#snippet children()} - {#if !device.upload_interval === null && device.upload_interval <= 0} -
    Device has invalid upload interval
    - {/if} - - {/snippet} -
    - - diff --git a/src/lib/components/UI/dashboard/DeviceCards.svelte b/src/lib/components/UI/dashboard/DeviceCards.svelte deleted file mode 100644 index 74150c4d..00000000 --- a/src/lib/components/UI/dashboard/DeviceCards.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - - -
    - {#each devices as device, index (device.dev_eui)} -
    - {#if viewType === 'list'} -
    selectDevice(device.dev_eui)} - onkeydown={(e) => e.key === 'Enter' && selectDevice(device.dev_eui)} - class="cursor-pointer" - class:selected={selectedDevice === device.dev_eui} - role="button" - tabindex="0" - aria-label="Select device {device.name || device.dev_eui}" - > - -
    - {:else} -
    selectDevice(device.dev_eui)} - onkeydown={(e) => e.key === 'Enter' && selectDevice(device.dev_eui)} - class="cursor-pointer" - class:selected={selectedDevice === device.dev_eui} - role="button" - tabindex="0" - aria-label="Select device {device.name || device.dev_eui}" - > - -
    - {/if} -
    - {/each} -
    - - diff --git a/src/lib/components/UI/dashboard/DeviceDataList.svelte b/src/lib/components/UI/dashboard/DeviceDataList.svelte deleted file mode 100644 index e5364cef..00000000 --- a/src/lib/components/UI/dashboard/DeviceDataList.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - - -
    - -
    - - -
    -
    -

    - {$_('Details')} -

    -
    - - {#each dataPoints as dataPointKey, index} - {#if device.latestData[dataPointKey] !== null} -
    -
    -
    - - {nameToEmoji(dataPointKey)} - - - {$_(dataPointKey)} - -
    - - - {#if dataPointKey === 'created_at'} -

    - {#if device.deviceType.data_table_v2 === 'cw_relay_data'} - -  {$_('ago')} - {:else} - -  {$_('ago')} - {/if} -

    - {:else} -
    - - {formatNumber({ key: dataPointKey, value: device.latestData[dataPointKey] })} - - - {nameToNotation(dataPointKey)} - -
    - {/if} -
    - - {#if dataPoints.length - 1 !== index} -
    - {:else} -
    - {/if} -
    - {/if} - {/each} -
    -
    diff --git a/src/lib/components/UI/dashboard/LocationsPanel.svelte b/src/lib/components/UI/dashboard/LocationsPanel.svelte deleted file mode 100644 index b90d5cf5..00000000 --- a/src/lib/components/UI/dashboard/LocationsPanel.svelte +++ /dev/null @@ -1,206 +0,0 @@ - - - -
    -

    {$_('Locations')}

    - -
    -
    - -
    - { - if (e.key === 'Enter') { - typeof window !== 'undefined' ? localStorage.setItem('dashboard_search', search) : null; - } - }} - /> - {#if search} - - {/if} -
    - -
    - {#each filteredLocations as location (location.location_id)} - - {/each} -
    -
    - - diff --git a/src/lib/components/UI/dashboard/components/Button.svelte b/src/lib/components/UI/dashboard/components/Button.svelte deleted file mode 100644 index 37cc003f..00000000 --- a/src/lib/components/UI/dashboard/components/Button.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - click()} - role="button" - tabindex="0" - onkeydown={(e: KeyboardEvent) => - (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), click())} - > - {text} - {#if iconPath} - - {/if} - - diff --git a/src/lib/components/UI/detail/DateRange.svelte b/src/lib/components/UI/detail/DateRange.svelte deleted file mode 100644 index 7bc8cf05..00000000 --- a/src/lib/components/UI/detail/DateRange.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -
    - - - {label} - - -
    - {#each ['start', 'end'] as const as type} - - {#snippet children({ segments })} - {#each segments as { part, value }, i (part + i)} -
    - {#if part === 'literal'} - - {value} - - {:else} - - {value} - - {/if} -
    - {/each} - {/snippet} -
    - - {#if type === 'start'} - - {/if} - {/each} - - - - -
    - - - - {#snippet children({ months, weekdays })} - - - - - - - - - - - - -
    - {#each months as month (month.value)} - - - - {#each weekdays as day (day)} - -
    {day.slice(0, 2)}
    -
    - {/each} -
    -
    - - - {#each month.weeks as weekDates (weekDates)} - - {#each weekDates as date (date)} - - - {date.day} - - - {/each} - - {/each} - -
    - {/each} -
    - {/snippet} -
    -
    -
    -
    - - diff --git a/src/lib/components/UI/form/Avatar.svelte b/src/lib/components/UI/form/Avatar.svelte deleted file mode 100644 index 921f8736..00000000 --- a/src/lib/components/UI/form/Avatar.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
    - {#if avatarUrl} - {avatarUrl - {:else} -
    - {/if} - -
    - - -
    -
    diff --git a/src/lib/components/UI/form/LanguageSelector.svelte b/src/lib/components/UI/form/LanguageSelector.svelte deleted file mode 100644 index 02aa33de..00000000 --- a/src/lib/components/UI/form/LanguageSelector.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/src/lib/components/UI/form/Select.svelte b/src/lib/components/UI/form/Select.svelte deleted file mode 100644 index 324459ff..00000000 --- a/src/lib/components/UI/form/Select.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/src/lib/components/UI/form/TextInput.svelte b/src/lib/components/UI/form/TextInput.svelte deleted file mode 100644 index d55b36c4..00000000 --- a/src/lib/components/UI/form/TextInput.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/lib/components/UI/form/UserPermissionsSelector.svelte b/src/lib/components/UI/form/UserPermissionsSelector.svelte deleted file mode 100644 index 8ac2f0a5..00000000 --- a/src/lib/components/UI/form/UserPermissionsSelector.svelte +++ /dev/null @@ -1,235 +0,0 @@ - - -
    -
    -

    {$_('current_users')}

    -
    -
    - {#if !ownerList || ownerList?.length === 0} -
    - {$_('no_additional_users')} -
    - {:else} - {#each ownerList as owner (owner.id)} - {#if !owner.profile?.email?.includes('@cropwatch.io')} -
    -
    -
    - - {owner.profile?.full_name?.charAt(0) || owner.profile?.email?.charAt(0) || 'U'} - -
    -
    -
    - {owner.profile?.full_name || owner.profile?.email || `User ${owner.user_id}`} -
    -
    - {owner.profile?.email || ''} -
    -
    -
    - -
    - {#if isDeviceOwner(owner.user_id)} - - {$_('owner')} - - {/if} - {#if canManagePermissions() && !isDeviceOwner(owner.user_id)} -
    - - { - updatingUserId = owner.user_id; - updatingUser = true; - - return async ({ result, update }) => { - if (result.type === 'success' && result.data?.success) { - await update({ invalidateAll: true }); - successToast( - (result.data as any).message || 'Permission updated successfully' - ); - } else if (result.type === 'success' && result.data?.error) { - errorToast((result.data as any).error); - } else { - errorToast('Failed to update permission'); - } - - updatingUser = false; - }; - }} - use:formValidation - > - - {#if updatingUser && updatingUserId === owner.user_id} - {$_('updating')} - {:else} - - {/if} - - -
    - {:else} - - {getPermissionName(owner.permission_level as number)} - - {/if} - {#if canDelete && !isDeviceOwner(owner.user_id)} - {@const disabled = removingUser && removingUserId === owner.user_id} - - {/if} -
    -
    - {/if} - {/each} - {/if} -
    -
    - - - {#snippet title()} - {$_('remove_user')} - {/snippet} - {#snippet body()} - {$_('confirm_remove_user')} - {/snippet} - {#snippet footer()} - - - {/snippet} - diff --git a/src/lib/components/UI/icons/CalendarIcon.svelte b/src/lib/components/UI/icons/CalendarIcon.svelte deleted file mode 100644 index 8838b278..00000000 --- a/src/lib/components/UI/icons/CalendarIcon.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/src/lib/components/UI/icons/ChevronLeftIcon.svelte b/src/lib/components/UI/icons/ChevronLeftIcon.svelte deleted file mode 100644 index f46817be..00000000 --- a/src/lib/components/UI/icons/ChevronLeftIcon.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/src/lib/components/UI/icons/ChevronRightIcon.svelte b/src/lib/components/UI/icons/ChevronRightIcon.svelte deleted file mode 100644 index 36bbcd14..00000000 --- a/src/lib/components/UI/icons/ChevronRightIcon.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/src/lib/components/UI/icons/MaterialIcon.svelte b/src/lib/components/UI/icons/MaterialIcon.svelte deleted file mode 100644 index 42a3e0ca..00000000 --- a/src/lib/components/UI/icons/MaterialIcon.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - {name} - - - diff --git a/src/lib/components/UI/overlay/Dialog.svelte b/src/lib/components/UI/overlay/Dialog.svelte deleted file mode 100644 index b33668c2..00000000 --- a/src/lib/components/UI/overlay/Dialog.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - { - event.preventDefault(); - open = false; - }} -> - {#if content} - {@render content()} - {:else} - {#if header} -
    - {@render header()} -
    - {:else if title} -

    - {@render title()} -

    - {/if} - {#if body} -
    - {@render body()} -
    - {/if} - {#if footer} -
    - {@render footer()} -
    - {/if} - {/if} -
    diff --git a/src/lib/components/UI/primitives/Collapse.svelte b/src/lib/components/UI/primitives/Collapse.svelte deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/components/UI/primitives/Duration.svelte b/src/lib/components/UI/primitives/Duration.svelte deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/components/UI/primitives/Icon.svelte b/src/lib/components/UI/primitives/Icon.svelte deleted file mode 100644 index cbb8ba6b..00000000 --- a/src/lib/components/UI/primitives/Icon.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {#if d} - - {/if} - diff --git a/src/lib/components/WeatherCalendar.svelte b/src/lib/components/WeatherCalendar.svelte deleted file mode 100644 index ce99c2e0..00000000 --- a/src/lib/components/WeatherCalendar.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - -
    -
    -

    - {monthStart.toFormat('MMMM yyyy')} -

    -
    - -
    - {#each weekdays as label (label)} - - {/each} -
    - -
    - {#if loading} -
    - Loading weather data... -
    - {/if} - - {#each calendarCells as cell (cell.key)} - {#if cell.type === 'blank'} - - {:else} - {@const day = cell as CalendarCellDay} -
    -
    - {weekdays[day.date.weekday % 7]} - {day.date.day} -
    - - {#if day.weather} -
    -
    - {formatTemp(day.weather.maxtemp_c)}°C - - Low {formatTemp(day.weather.mintemp_c)}°C - -
    -
    - {day.weather.condition.icon} -
    - - {day.weather.condition.text} - - - {formatPrecip(day.weather.totalprecip_mm)} mm - -
    -
    -
    - {:else} -
    - {/if} - - {#if day.events.length} -
    - {#each day.events as event, idx (event.id ?? idx)} -
    - {getEventTitle(event)} -
    - {/each} -
    - {/if} -
    - {/if} - {/each} -
    - -

    - Weather data by - - Open-Meteo - -

    -
    diff --git a/src/lib/components/charts/Vpd.svelte b/src/lib/components/charts/Vpd.svelte deleted file mode 100644 index 986914bf..00000000 --- a/src/lib/components/charts/Vpd.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - -
    diff --git a/src/lib/components/charts/WaterLevel.svelte b/src/lib/components/charts/WaterLevel.svelte deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/components/dashboard/CameraStream.svelte b/src/lib/components/dashboard/CameraStream.svelte deleted file mode 100644 index 2b9ccd52..00000000 --- a/src/lib/components/dashboard/CameraStream.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if device?.ip_log.length} -
    -

    {$_('Camera Stream')}

    - {#if device?.ip_log.length > 0} - {#each device.ip_log as log} - Camera Stream - {/each} - {:else} -

    {$_('No stream available')}

    - {/if} -
    -{/if} diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte new file mode 100644 index 00000000..3f0a6cfb --- /dev/null +++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte @@ -0,0 +1,409 @@ + + + + +
    + {#key filterKey} +
    + {#if locationCards.length === 0} +
    +

    {m.dashboard_no_matching_locations_title()}

    +

    {m.dashboard_no_matching_locations_body()}

    +
    + {:else} +
    + {#each visibleLocationCards as card, index (card.id)} +
    + { + if (target === 'location') { + openLocation(card); + return; + } + + if (target.startsWith('device-detail:')) { + openDeviceDetails(card, target.replace('device-detail:', '')); + } + }} + onTimerExpired={(label) => handleTimerExpired(card, label)} + /> +
    + + {#if enableInfiniteScroll && canLoadMore && index === prefetchTriggerIndex} + + {/if} + {/each} +
    + + {#if enableInfiniteScroll && canLoadMore} +

    + {m.dashboard_loading_more_locations_hint()} +

    + {/if} + {/if} +
    + {/key} +
    + + diff --git a/src/lib/components/dashboard/DashboardDeviceTable.svelte b/src/lib/components/dashboard/DashboardDeviceTable.svelte new file mode 100644 index 00000000..39f1122a --- /dev/null +++ b/src/lib/components/dashboard/DashboardDeviceTable.svelte @@ -0,0 +1,337 @@ + + +
    + {#key tableSourceKey} + + {#snippet cell(row: IDevice, col: CwColumnDef, defaultValue: string)} +
    + {#if col.key === 'created_at'} +
    + + {#if row.has_primary_data === false} + {m.dashboard_no_data_yet()} + {:else} + void loadSingleDevice(row)} + /> + {/if} +
    + {:else if (col.key === 'name' || col.key === 'location_name') && app.privacyModeEnabled} +
    + • • • • • • • • +
    + {:else} +
    + {#if col.key === 'soil_temperature_c'} + {getMetricDisplayValue(row.soil_temperature_c)} + {:else if col.key === 'temperature_c'} + {getMetricDisplayValue(row.temperature_c)} + {:else if col.key === 'humidity'} + {getMetricDisplayValue(row.humidity)} + {:else if col.key === 'soil_humidity'} + {getMetricDisplayValue(row.soil_humidity)} + {:else if col.key === 'co2'} + {getMetricDisplayValue(row.co2)} + {:else if col.key === 'alert_count'} + + {#if row.alert_count == null || row.alert_count === 0} + + {:else} + + {/if} + + {:else} + {defaultValue} + {/if} +
    + {/if} +
    + {/snippet} + + {#snippet rowActions(row: IDevice)} + openDeviceDetails(row)} + > + + + {/snippet} + + {#snippet actionsHeader()} + { + virtualScroll = !virtualScroll; + }} + > + {m.dashboard_virtual_scroll()} + {#if virtualScroll} + + {:else} + + {/if} + + {/snippet} +
    + {/key} +
    + + diff --git a/src/lib/components/dashboard/DashboardFilter.svelte b/src/lib/components/dashboard/DashboardFilter.svelte deleted file mode 100644 index 3c06f513..00000000 --- a/src/lib/components/dashboard/DashboardFilter.svelte +++ /dev/null @@ -1,157 +0,0 @@ - - -
    - -
    - - - {#if value} - - {/if} -
    -
    - - diff --git a/src/lib/components/dashboard/DateRangeSelector.svelte b/src/lib/components/dashboard/DateRangeSelector.svelte deleted file mode 100644 index 897dbb39..00000000 --- a/src/lib/components/dashboard/DateRangeSelector.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - -
    - -
    - -
    - - -
    - - - -
    - {#each ['start', 'end'] as const as type (type)} - - {#snippet children({ segments })} - {#each segments as { part, value }, i (part + i)} -
    - {#if part === 'literal'} - - {value} - - {:else} - - {value} - - {/if} -
    - {/each} - {/snippet} -
    - {#if type === 'start'} - - {/if} - {/each} - - - - -
    - - - - {#snippet children({ months, weekdays })} - - - - - - - - - - -
    - {#each months as month (month.value)} - - - - {#each weekdays as day (day)} - -
    {day.slice(0, 2)}
    -
    - {/each} -
    -
    - - {#each month.weeks as weekDates (weekDates)} - - {#each weekDates as date (date)} - - - {date.day} - - - {/each} - - {/each} - -
    - {/each} -
    - {/snippet} -
    -
    -
    -
    - - -
    - -
    - - {#if error} -

    {error}

    - {/if} -
    diff --git a/src/lib/components/dashboard/DeviceMap.svelte b/src/lib/components/dashboard/DeviceMap.svelte deleted file mode 100644 index 98f5d8a0..00000000 --- a/src/lib/components/dashboard/DeviceMap.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -{#if device} -
    -

    {$_('Map')}

    -
    - -
    -
    -{/if} diff --git a/src/lib/components/dashboard/LocationSidebar.svelte b/src/lib/components/dashboard/LocationSidebar.svelte deleted file mode 100644 index be43b8b9..00000000 --- a/src/lib/components/dashboard/LocationSidebar.svelte +++ /dev/null @@ -1,376 +0,0 @@ - - -
    -
    -
    - - {#if !collapsed} -
    -

    {$_('Locations')}

    -
    -
    - -
    - {/if} -
    -
    -
    - {#if collapsed} - - {:else} -
    -
    - - - -
    - { - if (e.key === 'Enter') { - browser ? localStorage.setItem('dashboard_search', localSearch) : null; - } - }} - /> - {#if localSearch} - - {/if} -
    - {/if} -
    - - {#if locations.length === 0 && !collapsed} -

    {$_('No locations found.')}

    - {:else} -
      - {#each locations - .filter((location: LocationWithCount) => { - if (hideEmptyLocations) return location.deviceCount > 0; - return true; - }) - .filter((location: LocationWithCount) => { - if (!search?.trim() || collapsed) return true; - return location.name.toLowerCase().includes(search.toLowerCase()); - }) as location (location.location_id)} -
    • - -
    • - {/each} -
    - {/if} -
    - - diff --git a/src/lib/components/dashboard/dashboard-device-data.spec.ts b/src/lib/components/dashboard/dashboard-device-data.spec.ts new file mode 100644 index 00000000..ec09ac52 --- /dev/null +++ b/src/lib/components/dashboard/dashboard-device-data.spec.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest'; +import type { DeviceDto, DevicePrimaryDataDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; +import { + applyDashboardLatestReadings, + mapDashboardDeviceMetadataToDevice, + mapDashboardPrimaryDataToDevice, + mergeDashboardDevices +} from './dashboard-device-data'; + +describe('dashboard-device-data helpers', () => { + it('maps device metadata into offline dashboard device rows', () => { + const payload: DeviceDto = { + dev_eui: 'dev-9', + name: 'Unreported Sensor', + group: 'air', + location_id: 17, + cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'], + cw_locations: [{ name: 'North Loft' }] + }; + + expect(mapDashboardDeviceMetadataToDevice(payload)).toEqual({ + dev_eui: 'dev-9', + name: 'Unreported Sensor', + location_name: 'North Loft', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date(0), + has_primary_data: false, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_temperature_c: null, + soil_humidity: null, + location_id: 17, + cwloading: false, + device_type_id: undefined + }); + }); + + it('reads embedded location metadata when cw_locations is a single object', () => { + const payload = { + dev_eui: 'dev-10', + name: 'Lobby Sensor', + group: 'air', + cw_locations: { + location_id: 88, + name: 'Toyotama Floor 2' + }, + cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'] + } satisfies Omit; + + expect(mapDashboardDeviceMetadataToDevice(payload)).toMatchObject({ + dev_eui: 'dev-10', + data_table: 'cw_air_data', + location_id: 88, + location_name: 'Toyotama Floor 2', + has_primary_data: false + }); + }); + + it('maps latest primary API payloads into dashboard device rows', () => { + const payload: DevicePrimaryDataDto = { + dev_eui: 'dev-1', + name: 'Canopy 1', + location_name: 'Zone A', + group: 'air', + created_at: '2026-03-13T00:00:00.000Z', + co2: 914, + humidity: 63.2, + temperature_c: 24.8, + location_id: 42 + }; + + expect(mapDashboardPrimaryDataToDevice(payload)).toEqual({ + dev_eui: 'dev-1', + name: 'Canopy 1', + location_name: 'Zone A', + group: 'air', + created_at: new Date('2026-03-13T00:00:00.000Z'), + has_primary_data: true, + co2: 914, + humidity: 63.2, + temperature_c: 24.8, + soil_temperature_c: null, + soil_humidity: null, + location_id: 42, + cwloading: false, + device_type_id: undefined, + raw_data: { + dev_eui: 'dev-1', + name: 'Canopy 1', + location_name: 'Zone A', + group: 'air', + created_at: '2026-03-13T00:00:00.000Z', + co2: 914, + humidity: 63.2, + temperature_c: 24.8, + location_id: 42 + } + }); + }); + + it('maps soil primary API payloads into soil-specific dashboard columns', () => { + const payload = { + dev_eui: 'soil-1', + name: 'Soil Bed 1', + location_name: 'Zone S', + group: 'soil', + data_table: 'cw_soil_data', + created_at: '2026-03-13T00:00:00.000Z', + temperature_c: 18.6, + moisture: 34.5, + location_id: 77 + } satisfies DevicePrimaryDataDto; + + expect(mapDashboardPrimaryDataToDevice(payload)).toMatchObject({ + dev_eui: 'soil-1', + data_table: 'cw_soil_data', + temperature_c: 18.6, + soil_temperature_c: 18.6, + soil_humidity: 34.5, + location_id: 77 + }); + }); + + it('applies and merges the latest readings while preserving device order', () => { + const currentDevices: IDevice[] = [ + { + dev_eui: 'dev-1', + name: 'Canopy 1', + location_name: 'Zone A', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date('2026-03-13T00:00:00.000Z'), + has_primary_data: true, + co2: 914, + humidity: 63.2, + temperature_c: 24.8, + soil_temperature_c: null, + soil_humidity: null, + location_id: 42, + cwloading: true + }, + { + dev_eui: 'dev-2', + name: 'Canopy 2', + location_name: 'Zone B', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date('2026-03-13T00:05:00.000Z'), + has_primary_data: true, + co2: 880, + humidity: 59.1, + temperature_c: 23.4, + soil_temperature_c: null, + soil_humidity: null, + location_id: 99 + } + ]; + const latestDev1 = mapDashboardPrimaryDataToDevice({ + dev_eui: 'dev-1', + name: 'Canopy 1', + location_name: 'Zone A', + group: 'air', + created_at: '2026-03-13T00:10:00.000Z', + co2: 920, + humidity: 61.7, + temperature_c: 25.1, + location_id: 42 + }); + const latestDev3 = mapDashboardPrimaryDataToDevice({ + dev_eui: 'dev-3', + name: 'Canopy 3', + location_name: 'Zone C', + group: 'air', + created_at: '2026-03-13T00:15:00.000Z', + co2: 901, + humidity: 58.4, + temperature_c: 22.9, + location_id: 100 + }); + const metadataOnlyDev4 = mapDashboardDeviceMetadataToDevice({ + dev_eui: 'dev-4', + name: 'Offline Device', + group: 'air', + location_id: 200, + location_name: 'Zone D', + cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'] + }); + + const appliedTarget = structuredClone(currentDevices[0]); + applyDashboardLatestReadings(appliedTarget, latestDev1); + expect(appliedTarget).toMatchObject({ + created_at: new Date('2026-03-13T00:10:00.000Z'), + has_primary_data: true, + data_table: 'cw_air_data', + co2: 920, + humidity: 61.7, + temperature_c: 25.1, + soil_temperature_c: null, + soil_humidity: null + }); + + const mergedDevices = mergeDashboardDevices(currentDevices, [ + metadataOnlyDev4, + latestDev3, + latestDev1 + ]); + expect(mergedDevices.map((device) => device.dev_eui)).toEqual([ + 'dev-1', + 'dev-2', + 'dev-4', + 'dev-3' + ]); + expect(mergedDevices[0]).toMatchObject({ + dev_eui: 'dev-1', + created_at: new Date('2026-03-13T00:10:00.000Z'), + has_primary_data: true, + cwloading: true + }); + expect(mergedDevices[2]).toMatchObject({ + dev_eui: 'dev-4', + location_name: 'Zone D', + created_at: new Date(0), + has_primary_data: false, + cwloading: false + }); + expect(mergedDevices[3]).toMatchObject({ + dev_eui: 'dev-3', + location_name: 'Zone C', + has_primary_data: true, + cwloading: false + }); + }); + + it('preserves metadata when latest primary data omits location fields', () => { + const currentDevices: IDevice[] = [ + { + dev_eui: 'dev-11', + name: 'Hallway Sensor', + location_name: 'とよたま2階', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date(0), + has_primary_data: false, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_temperature_c: null, + soil_humidity: null, + location_id: 501, + cwloading: false + } + ]; + + const mergedDevices = mergeDashboardDevices(currentDevices, [ + mapDashboardPrimaryDataToDevice({ + dev_eui: 'dev-11', + name: '', + location_name: '', + group: '', + created_at: '2026-03-13T00:20:00.000Z', + co2: 777, + humidity: 48.5, + temperature_c: 21.3, + location_id: 0 + }) + ]); + + expect(mergedDevices).toHaveLength(1); + expect(mergedDevices[0]).toMatchObject({ + dev_eui: 'dev-11', + name: 'Hallway Sensor', + location_name: 'とよたま2階', + group: 'air', + location_id: 501, + co2: 777, + humidity: 48.5, + temperature_c: 21.3, + has_primary_data: true + }); + }); + + it('preserves soil classification when a refresh payload omits explicit soil fields', () => { + const currentDevices: IDevice[] = [ + { + dev_eui: 'soil-2', + name: 'Soil Plot 2', + location_name: 'Bed B', + group: 'soil', + data_table: 'cw_soil_data', + created_at: new Date('2026-03-13T00:00:00.000Z'), + has_primary_data: true, + co2: 0, + humidity: 0, + temperature_c: 18.1, + soil_temperature_c: 18.1, + soil_humidity: 31.4, + location_id: 612, + cwloading: false + } + ]; + + const mergedDevices = mergeDashboardDevices(currentDevices, [ + mapDashboardPrimaryDataToDevice({ + dev_eui: 'soil-2', + name: 'Soil Plot 2', + location_name: 'Bed B', + group: 'soil', + created_at: '2026-03-13T00:30:00.000Z', + temperature_c: 19.2, + location_id: 612 + }) + ]); + + expect(mergedDevices[0]).toMatchObject({ + dev_eui: 'soil-2', + data_table: 'cw_soil_data', + temperature_c: 19.2, + soil_temperature_c: 19.2, + soil_humidity: 31.4 + }); + }); +}); diff --git a/src/lib/components/dashboard/dashboard-device-data.ts b/src/lib/components/dashboard/dashboard-device-data.ts new file mode 100644 index 00000000..c5185e9e --- /dev/null +++ b/src/lib/components/dashboard/dashboard-device-data.ts @@ -0,0 +1,334 @@ +import type { DeviceDto, DevicePrimaryDataDto, DeviceTypeDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toStringValue(value: unknown): string { + return typeof value === 'string' ? value : value == null ? '' : String(value); +} + +function toNumberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : Number(value ?? 0) || 0; +} + +function toOptionalNumberValue(value: unknown): number | null { + if (value == null || value === '') { + return null; + } + + const nextValue = typeof value === 'number' && Number.isFinite(value) ? value : Number(value); + return Number.isFinite(nextValue) ? nextValue : null; +} + +function toDateValue(value: unknown): Date { + if (value instanceof Date) { + return value; + } + + const nextValue = new Date(toStringValue(value)); + return Number.isNaN(nextValue.getTime()) ? new Date(0) : nextValue; +} + +function getDashboardEmbeddedLocations( + device: DeviceDto | Record +): Record[] { + if (Array.isArray(device.cw_locations)) { + return device.cw_locations.filter(isRecord); + } + + if (isRecord(device.cw_locations)) { + return [device.cw_locations]; + } + + return []; +} + +function getDashboardDeviceLocationName(device: DeviceDto | Record): string { + const directName = toStringValue(device.location_name).trim(); + if (directName) { + return directName; + } + + for (const location of getDashboardEmbeddedLocations(device)) { + const locationName = toStringValue(location?.name).trim(); + if (locationName) { + return locationName; + } + } + + return ''; +} + +function getDashboardDeviceLocationId(device: DeviceDto | Record): number { + const directId = toNumberValue(device.location_id); + if (directId > 0) { + return directId; + } + + for (const location of getDashboardEmbeddedLocations(device)) { + const locationId = toNumberValue(location.location_id); + if (locationId > 0) { + return locationId; + } + } + + return directId; +} + +function preferIncomingText( + incoming: string | undefined, + current: string | undefined +): string | undefined { + return incoming?.trim() ? incoming : current; +} + +function preferIncomingLocationId(incoming: number, current: number): number { + return incoming > 0 ? incoming : current; +} + +function getDeviceDataTable( + device: DeviceDto | DevicePrimaryDataDto | Record +): string { + const directDataTable = toStringValue(device.data_table_v2 ?? device.data_table).trim(); + if (directDataTable) { + return directDataTable; + } + + if (isRecord(device.cw_device_type)) { + return toStringValue( + device.cw_device_type.data_table_v2 ?? device.cw_device_type.data_table + ).trim(); + } + + return ''; +} + +function toOptionalStringValue(value: unknown): string | undefined { + if (value == null || value === '') return undefined; + return String(value); +} + +function extractDeviceTypeId( + device: DeviceDto | DevicePrimaryDataDto | Record +): number | undefined { + const raw = (device as Record).type; + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw; + return undefined; +} + +/** Configuration for a device type, keyed by cw_device_type.model. */ +export interface DeviceTypeConfig { + primary_data_key?: string; + secondary_data_key?: string; + primary_data_notation?: string; + secondary_data_notation?: string; + primary_multiplier?: number | null; + primary_divider?: number | null; + secondary_multiplier?: number | null; + secondary_divider?: number | null; +} + +/** Lookup maps built from the `/devices/device-types` endpoint. */ +export interface DeviceTypeLookup { + /** model string → full config */ + byModel: Record; + /** cw_device_type.id → model string (bridges CwDevice.type FK) */ + idToModel: Record; +} + +/** + * Build lookup maps from the full device-type list (`getDeviceTypes()`). + * Keyed by `cw_device_type.model` with an id→model bridge so individual + * devices can resolve their type via their numeric FK (`device.type`). + */ +export function buildDeviceTypeLookup( + deviceTypes: DeviceTypeDto[] +): DeviceTypeLookup { + const byModel: Record = {}; + const idToModel: Record = {}; + + for (const dt of deviceTypes) { + const model = toOptionalStringValue(dt.model); + if (!model) continue; + + const id = typeof dt.id === 'number' && Number.isFinite(dt.id) ? dt.id : undefined; + if (id != null) { + idToModel[id] = model; + } + + if (byModel[model]) continue; + + byModel[model] = { + primary_data_key: toOptionalStringValue(dt.primary_data_v2), + secondary_data_key: toOptionalStringValue(dt.secondary_data_v2), + primary_data_notation: toOptionalStringValue(dt.primary_data_notation), + secondary_data_notation: toOptionalStringValue(dt.secondary_data_notation), + primary_multiplier: toOptionalNumberValue(dt.primary_multiplier), + primary_divider: toOptionalNumberValue(dt.primary_divider), + secondary_multiplier: toOptionalNumberValue(dt.secondary_multiplier), + secondary_divider: toOptionalNumberValue(dt.secondary_divider) + }; + } + + return { byModel, idToModel }; +} + +/** Resolve a DeviceTypeConfig for an IDevice using its type FK. */ +export function resolveDeviceTypeConfig( + device: IDevice, + lookup: DeviceTypeLookup | undefined +): DeviceTypeConfig | undefined { + if (!lookup || !device.device_type_id) return undefined; + const model = lookup.idToModel[device.device_type_id]; + return model ? lookup.byModel[model] : undefined; +} + +export function mapDashboardDeviceMetadataToDevice( + device: DeviceDto | Record +): IDevice { + const dataTable = getDeviceDataTable(device); + + return { + dev_eui: toStringValue(device.dev_eui), + name: toStringValue(device.name ?? device.dev_eui), + location_name: getDashboardDeviceLocationName(device), + group: toStringValue(device.group), + data_table: dataTable || undefined, + created_at: new Date(0), + has_primary_data: false, + co2: 0, + humidity: 0, + temperature_c: 0, + soil_temperature_c: null, + soil_humidity: null, + location_id: getDashboardDeviceLocationId(device), + cwloading: false, + device_type_id: extractDeviceTypeId(device) + }; +} + +export function mapDashboardPrimaryDataToDevice( + device: DevicePrimaryDataDto | Record +): IDevice { + const dataTable = getDeviceDataTable(device); + const soilHumidity = toOptionalNumberValue(device.moisture); + const isSoilDevice = dataTable === 'cw_soil_data' || soilHumidity != null; + + // Preserve the full raw payload so dynamic column keys can be resolved + const raw_data: Record = {}; + for (const [key, value] of Object.entries(device)) { + if (key !== 'cw_device_type' && key !== 'cw_locations' && key !== 'cw_device_owners') { + raw_data[key] = value; + } + } + + return { + dev_eui: toStringValue(device.dev_eui), + name: toStringValue(device.name), + location_name: toStringValue(device.location_name), + group: toStringValue(device.group), + ...(dataTable ? { data_table: dataTable } : {}), + created_at: toDateValue(device.created_at), + has_primary_data: true, + co2: toNumberValue(device.co2), + humidity: toNumberValue(device.humidity), + temperature_c: toNumberValue(device.temperature_c), + soil_temperature_c: isSoilDevice ? toOptionalNumberValue(device.temperature_c) : null, + soil_humidity: soilHumidity, + location_id: toNumberValue(device.location_id), + cwloading: false, + raw_data, + device_type_id: extractDeviceTypeId(device) + }; +} + +export function applyDashboardLatestReadings(target: IDevice, source: IDevice): void { + const resolvedDataTable = preferIncomingText(source.data_table, target.data_table); + const isSoilDevice = resolvedDataTable === 'cw_soil_data'; + + target.temperature_c = source.temperature_c; + target.co2 = source.co2; + target.humidity = source.humidity; + target.soil_temperature_c = + source.soil_temperature_c ?? + (isSoilDevice ? source.temperature_c : null) ?? + target.soil_temperature_c ?? + null; + target.soil_humidity = + source.soil_humidity ?? (isSoilDevice ? (target.soil_humidity ?? null) : null); + target.created_at = source.created_at; + target.has_primary_data = source.has_primary_data; + target.location_id = preferIncomingLocationId(source.location_id, target.location_id); + target.location_name = preferIncomingText(source.location_name, target.location_name) ?? ''; + target.group = preferIncomingText(source.group, target.group); + target.alert_count = source.alert_count ?? target.alert_count ?? 0; + target.data_table = resolvedDataTable; + target.name = preferIncomingText(source.name, target.name) ?? target.dev_eui; + + // Preserve device_type_id (metadata device has it, primary data refresh may not) + target.device_type_id = source.device_type_id ?? target.device_type_id; + + // Merge raw sensor data + if (source.raw_data) { + target.raw_data = { ...target.raw_data, ...source.raw_data }; + } +} + +export function mergeDashboardDevices( + currentDevices: IDevice[], + latestDevices: IDevice[] +): IDevice[] { + if (latestDevices.length === 0) { + return currentDevices; + } + + const latestByDevEui = new Map(latestDevices.map((device) => [device.dev_eui, device] as const)); + + const mergedDevices = currentDevices.map((device) => { + const latestDevice = latestByDevEui.get(device.dev_eui); + if (!latestDevice) { + return device; + } + + latestByDevEui.delete(device.dev_eui); + const resolvedDataTable = preferIncomingText(latestDevice.data_table, device.data_table); + const isSoilDevice = resolvedDataTable === 'cw_soil_data'; + + return { + ...device, + ...latestDevice, + name: preferIncomingText(latestDevice.name, device.name) ?? device.dev_eui, + group: preferIncomingText(latestDevice.group, device.group), + data_table: resolvedDataTable, + location_name: preferIncomingText(latestDevice.location_name, device.location_name) ?? '', + location_id: preferIncomingLocationId(latestDevice.location_id, device.location_id), + has_primary_data: latestDevice.has_primary_data ?? device.has_primary_data ?? false, + soil_temperature_c: + latestDevice.soil_temperature_c ?? + (isSoilDevice ? latestDevice.temperature_c : null) ?? + (isSoilDevice ? (device.soil_temperature_c ?? null) : null), + soil_humidity: + latestDevice.soil_humidity ?? (isSoilDevice ? (device.soil_humidity ?? null) : null), + cwloading: device.cwloading ?? latestDevice.cwloading ?? false, + alert_count: latestDevice.alert_count ?? device.alert_count ?? 0, + // Preserve device_type_id (metadata device has it, primary data refresh may not) + device_type_id: device.device_type_id ?? latestDevice.device_type_id, + raw_data: latestDevice.raw_data + ? { ...device.raw_data, ...latestDevice.raw_data } + : device.raw_data + }; + }); + + for (const latestDevice of latestByDevEui.values()) { + mergedDevices.push({ + ...latestDevice, + has_primary_data: latestDevice.has_primary_data ?? false, + cwloading: latestDevice.cwloading ?? false, + alert_count: latestDevice.alert_count ?? 0 + }); + } + + return mergedDevices; +} diff --git a/src/lib/components/dashboard/dashboard-filter-values.ts b/src/lib/components/dashboard/dashboard-filter-values.ts new file mode 100644 index 00000000..d33713ff --- /dev/null +++ b/src/lib/components/dashboard/dashboard-filter-values.ts @@ -0,0 +1,48 @@ +const DASHBOARD_FILTER_VALUE_KEYS = [ + 'group', + 'location_group', + 'locationGroup', + 'name', + 'value', + 'label' +] as const; + +function toTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function readDashboardFilterValue(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + + if (!value || typeof value !== 'object') { + return ''; + } + + const record = value as Record; + for (const key of DASHBOARD_FILTER_VALUE_KEYS) { + const candidate = toTrimmedString(record[key]); + if (candidate) { + return candidate; + } + } + + return ''; +} + +export function normalizeDashboardFilterValues( + values: readonly unknown[] | null | undefined +): string[] { + if (!Array.isArray(values)) { + return []; + } + + const normalizedValues = values + .map((value) => readDashboardFilterValue(value)) + .filter((value) => value.length > 0); + + return Array.from(new Set(normalizedValues)).sort((left, right) => + left.localeCompare(right) + ); +} diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts new file mode 100644 index 00000000..da88a528 --- /dev/null +++ b/src/lib/components/dashboard/device-cards.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { LocationDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; +import { + buildDashboardLocationSensorCards, + DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES +} from './device-cards'; + +describe('device-cards helpers', () => { + it('groups devices by location, sorts titles, and disambiguates duplicate labels', () => { + const devices: IDevice[] = [ + { + dev_eui: 'dev-2', + name: 'Canopy', + location_name: 'Zone B', + group: 'air', + created_at: new Date('2026-03-13T00:00:00.000Z'), + co2: 880, + humidity: 54, + temperature_c: 22, + location_id: 2 + }, + { + dev_eui: 'dev-1', + name: 'Canopy', + location_name: 'Zone B', + group: 'air', + created_at: new Date('2026-03-13T00:01:00.000Z'), + co2: 901, + humidity: 58, + temperature_c: 23, + location_id: 2 + }, + { + dev_eui: 'dev-3', + name: 'Atrium Sensor', + location_name: 'Atrium', + group: 'air', + created_at: new Date('2026-03-13T00:02:00.000Z'), + co2: 915, + humidity: 62, + temperature_c: 25, + location_id: 1 + } + ]; + const locations: LocationDto[] = [ + { + location_id: 2, + name: 'Zone B', + created_at: '2026-03-01T00:00:00.000Z' + } as LocationDto, + { + location_id: 1, + name: 'Atrium', + created_at: '2026-03-01T00:00:00.000Z' + } as LocationDto + ]; + + const cards = buildDashboardLocationSensorCards(devices, locations); + + expect(cards.map((card) => card.title)).toEqual(['Atrium', 'Zone B']); + expect(cards[1]?.devices.map((device) => device.label)).toEqual([ + 'Canopy (dev-1)', + 'Canopy (dev-2)' + ]); + expect(cards[1]?.deviceRouteParamsByLabel['Canopy (dev-1)']).toEqual({ + devEui: 'dev-1', + locationId: 2 + }); + }); + + it('marks stale devices offline and removes the alarm window after one hour', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00.000Z')); + + const cards = buildDashboardLocationSensorCards( + [ + { + dev_eui: 'dev-1', + name: 'Recent Sensor', + location_name: 'Zone A', + group: 'air', + created_at: new Date('2026-03-13T11:56:00.000Z'), + co2: 900, + humidity: 58, + temperature_c: 23, + location_id: 1 + }, + { + dev_eui: 'dev-2', + name: 'Old Sensor', + location_name: 'Zone A', + group: 'air', + created_at: new Date('2026-03-13T10:30:00.000Z'), + co2: 860, + humidity: 52, + temperature_c: 20, + location_id: 1 + } + ], + [ + { + location_id: 1, + name: 'Zone A', + created_at: '2026-03-01T00:00:00.000Z' + } as LocationDto + ] + ); + + expect(cards[0]?.devices[0]).toMatchObject({ + label: 'Old Sensor', + status: 'offline', + expectedUpdateAfterMinutes: undefined + }); + expect(cards[0]?.devices[1]).toMatchObject({ + label: 'Recent Sensor', + status: 'online', + expectedUpdateAfterMinutes: DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES + }); + + vi.useRealTimers(); + }); + + it('keeps metadata-only devices offline without arming an overdue timer', () => { + const cards = buildDashboardLocationSensorCards( + [ + { + dev_eui: 'dev-5', + name: 'No Data Yet', + location_name: 'Zone X', + group: 'air', + created_at: new Date(0), + has_primary_data: false, + co2: 0, + humidity: 0, + temperature_c: 0, + location_id: 5 + } + ], + [ + { + location_id: 5, + name: 'Zone X', + created_at: '2026-03-01T00:00:00.000Z' + } as LocationDto + ] + ); + + expect(cards[0]?.devices[0]).toMatchObject({ + label: 'No Data Yet', + status: 'offline', + detailRows: [ + expect.objectContaining({ label: 'Temperature', value: 'No reading' }), + expect.objectContaining({ label: 'Humidity', value: 'No reading' }), + expect.objectContaining({ label: 'Last Update', value: 'No primary data yet' }) + ] + }); + }); +}); diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts new file mode 100644 index 00000000..ecc8053d --- /dev/null +++ b/src/lib/components/dashboard/device-cards.ts @@ -0,0 +1,205 @@ +import type { CwSensorCardDetailRow, CwSensorCardDevice } from '@cropwatchdevelopment/cwui'; +import type { LocationDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; +import { resolveDeviceTypeConfig, type DeviceTypeLookup } from './dashboard-device-data'; + +export const DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES = 10; +export const DASHBOARD_SENSOR_CARD_LOCATION_BATCH_SIZE = 10; +export const DASHBOARD_SENSOR_CARD_PREFETCH_REMAINING = 5; + +const DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MS = + DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES * 60_000; + +/** Stop arming the CwDuration alarm once a device is stale beyond this (ms). */ +const DASHBOARD_SENSOR_CARD_ALARM_CUTOFF_MS = 60 * 60_000; // 1 hour + +export interface DashboardLocationSensorCard { + id: string; + locationId: number; + title: string; + devices: CwSensorCardDevice[]; + deviceRouteParamsByLabel: Record; +} + +function getDeviceBaseLabel(device: IDevice): string { + return device.name.trim() || device.dev_eui; +} + +function getDeviceLabel(device: IDevice, duplicateCounts: Map): string { + const baseLabel = getDeviceBaseLabel(device); + return (duplicateCounts.get(baseLabel) ?? 0) > 1 ? `${baseLabel} (${device.dev_eui})` : baseLabel; +} + +function applyTransform(rawValue: unknown, multiplier?: number | null, divider?: number | null): number { + let value = typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0) || 0; + if (multiplier != null && multiplier !== 0) { + value *= multiplier; + } + if (divider != null && divider !== 0) { + value /= divider; + } + return value; +} + +function getDeviceStatus(device: IDevice): 'online' | 'offline' { + if (device.has_primary_data === false) { + return 'offline'; + } + + return Date.now() - new Date(device.created_at).getTime() > + DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MS + ? 'offline' + : 'online'; +} + +function buildUnavailableDetailRows(label: string, typeConfig: DeviceTypeConfig | undefined): CwSensorCardDetailRow[] { + const primaryLabel = typeConfig?.primary_data_key ?? 'Temperature'; + const secondaryLabel = typeConfig?.secondary_data_key ?? 'Humidity'; + return [ + { + id: `${label}-primary`, + label: primaryLabel, + value: 'No reading', + icon: 'thermo' + }, + { + id: `${label}-secondary`, + label: secondaryLabel, + value: 'No reading', + icon: 'drop' + }, + { + id: `${label}-updated`, + label: 'Last Update', + value: 'No primary data yet', + icon: 'timer' + } + ]; +} + +function getLocationTitle( + locationId: number, + locationsById: Map, + locationDevices: IDevice[] +): string { + const location = locationsById.get(locationId); + if (location?.name?.trim()) { + return location.name.trim(); + } + + const deviceLocationName = locationDevices.find((device) => + device.location_name.trim() + )?.location_name; + if (deviceLocationName) { + return deviceLocationName.trim(); + } + + return locationId > 0 ? `Location ${locationId}` : 'Unassigned'; +} + +export function buildDashboardLocationSensorCards( + devices: IDevice[], + locations: LocationDto[], + deviceTypeLookup?: DeviceTypeLookup +): DashboardLocationSensorCard[] { + const locationsById = new Map( + locations.map((location) => [Number(location.location_id), location] as const) + ); + const devicesByLocationId = new Map(); + + for (const device of devices) { + const locationId = Number(device.location_id); + const locationDevices = devicesByLocationId.get(locationId); + + if (locationDevices) { + locationDevices.push(device); + continue; + } + + devicesByLocationId.set(locationId, [device]); + } + + return Array.from(devicesByLocationId.entries()) + .map(([locationId, locationDevices]) => { + const sortedLocationDevices = [...locationDevices].sort( + (left, right) => + getDeviceBaseLabel(left).localeCompare(getDeviceBaseLabel(right), undefined, { + numeric: true, + sensitivity: 'base' + }) || + left.dev_eui.localeCompare(right.dev_eui, undefined, { + numeric: true, + sensitivity: 'base' + }) + ); + const duplicateCounts = new Map(); + + for (const device of sortedLocationDevices) { + const baseLabel = getDeviceBaseLabel(device); + duplicateCounts.set(baseLabel, (duplicateCounts.get(baseLabel) ?? 0) + 1); + } + + const deviceRouteParamsByLabel: DashboardLocationSensorCard['deviceRouteParamsByLabel'] = {}; + const sensorDevices = sortedLocationDevices.map((device) => { + const label = getDeviceLabel(device, duplicateCounts); + + deviceRouteParamsByLabel[label] = { + devEui: device.dev_eui, + locationId: Number(device.location_id) + }; + + if (device.has_primary_data === false) { + const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); + return { + label, + primaryValue: 0, + primaryUnit: typeConfig?.primary_data_notation ?? '°C', + status: 'offline', + detailRows: buildUnavailableDetailRows(label, typeConfig) + } satisfies CwSensorCardDevice; + } + + const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); + const primaryKey = typeConfig?.primary_data_key; + const secondaryKey = typeConfig?.secondary_data_key; + + // Resolve primary value: try dynamic key from raw_data, fall back to temperature_c + const rawPrimary = primaryKey && device.raw_data?.[primaryKey] !== undefined + ? device.raw_data[primaryKey] + : device.temperature_c; + const primaryValue = applyTransform(rawPrimary, typeConfig?.primary_multiplier, typeConfig?.primary_divider); + + // Resolve secondary value: try dynamic key from raw_data, fall back to humidity + const rawSecondary = secondaryKey && device.raw_data?.[secondaryKey] !== undefined + ? device.raw_data[secondaryKey] + : device.humidity; + const secondaryValue = applyTransform(rawSecondary, typeConfig?.secondary_multiplier, typeConfig?.secondary_divider); + + const staleness = Date.now() - new Date(device.created_at).getTime(); + return { + label, + primaryValue, + primaryUnit: typeConfig?.primary_data_notation ?? '°C', + secondaryValue, + secondaryUnit: typeConfig?.secondary_data_notation ?? '%', + status: getDeviceStatus(device), + lastUpdated: device.created_at, + expectedUpdateAfterMinutes: + staleness <= DASHBOARD_SENSOR_CARD_ALARM_CUTOFF_MS + ? DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES + : undefined + } satisfies CwSensorCardDevice; + }); + + return { + id: `location:${locationId}`, + locationId, + title: getLocationTitle(locationId, locationsById, sortedLocationDevices), + devices: sensorDevices, + deviceRouteParamsByLabel + } satisfies DashboardLocationSensorCard; + }) + .sort((left, right) => + left.title.localeCompare(right.title, undefined, { numeric: true, sensitivity: 'base' }) + ); +} diff --git a/src/lib/components/dashboard/device-table.spec.ts b/src/lib/components/dashboard/device-table.spec.ts new file mode 100644 index 00000000..17acb45d --- /dev/null +++ b/src/lib/components/dashboard/device-table.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import type { CwTableQuery } from '@cropwatchdevelopment/cwui'; +import type { LocationDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; +import { + countDashboardDevices, + queryDashboardDevices, + type DashboardDeviceFilters +} from './device-table'; + +const devices: IDevice[] = [ + { + dev_eui: 'dev-1', + name: 'Alpha Canopy', + location_name: 'Room A', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date('2026-03-09T00:00:00Z'), + co2: 900, + humidity: 51, + temperature_c: 24, + soil_temperature_c: null, + soil_humidity: null, + location_id: 1 + }, + { + dev_eui: 'dev-2', + name: 'Beta Canopy', + location_name: 'Room B', + group: 'air', + data_table: 'cw_air_data', + created_at: new Date('2026-03-09T01:00:00Z'), + co2: 870, + humidity: 54, + temperature_c: 23, + soil_temperature_c: null, + soil_humidity: null, + location_id: 2 + }, + { + dev_eui: 'dev-3', + name: 'Gamma Soil', + location_name: 'Room C', + group: 'soil', + data_table: 'cw_soil_data', + created_at: new Date('2026-03-09T02:00:00Z'), + co2: 0, + humidity: 0, + temperature_c: 18, + soil_temperature_c: 18, + soil_humidity: 37, + location_id: 3 + } +]; + +const locations: LocationDto[] = [ + { + location_id: 1, + name: 'Room A', + created_at: '2026-03-01T00:00:00Z', + location_group: 'North' + } as LocationDto, + { + location_id: 2, + name: 'Room B', + created_at: '2026-03-01T00:00:00Z', + location_group: 'North' + } as LocationDto, + { + location_id: 3, + name: 'Room C', + created_at: '2026-03-01T00:00:00Z', + location_group: 'South' + } as LocationDto +]; + +function createQuery(overrides: Partial = {}): CwTableQuery { + return { + page: 1, + pageSize: 10, + search: '', + sort: null, + filters: {}, + signal: new AbortController().signal, + ...overrides + }; +} + +describe('device-table query helpers', () => { + it('counts devices using group, location-group, and location filters', () => { + const filters: DashboardDeviceFilters = { + group: 'air', + locationGroup: 'North', + location: '2' + }; + + expect(countDashboardDevices(devices, locations, filters)).toBe(1); + }); + + it('applies search, sort, and slicing across the full filtered dataset', () => { + const filters: DashboardDeviceFilters = { + group: 'air', + locationGroup: 'North', + location: '' + }; + + const result = queryDashboardDevices( + devices, + locations, + filters, + createQuery({ + page: 1, + pageSize: 1, + search: 'canopy', + sort: { column: 'created_at', direction: 'desc' } + }) + ); + + expect(result.total).toBe(2); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]?.dev_eui).toBe('dev-2'); + }); + + it('sorts optional soil columns numerically when non-soil devices have no soil readings', () => { + const filters: DashboardDeviceFilters = { + group: '', + locationGroup: '', + location: '' + }; + + const result = queryDashboardDevices( + devices, + locations, + filters, + createQuery({ + sort: { column: 'soil_humidity', direction: 'desc' } + }) + ); + + expect(result.rows.map((device) => device.dev_eui)).toEqual(['dev-3', 'dev-1', 'dev-2']); + }); +}); diff --git a/src/lib/components/dashboard/device-table.ts b/src/lib/components/dashboard/device-table.ts new file mode 100644 index 00000000..e7ee4a94 --- /dev/null +++ b/src/lib/components/dashboard/device-table.ts @@ -0,0 +1,200 @@ +import type { CwTableQuery, CwTableResult } from '@cropwatchdevelopment/cwui'; +import type { LocationDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; + +export interface DashboardDeviceFilters { + group: string; + locationGroup: string; + location: string; +} + +export const DASHBOARD_DEVICE_BATCH_SIZE = 25; +export const DASHBOARD_DEVICE_PAGE_SIZE_OPTIONS = [25, 50, 75, 100]; +export const DASHBOARD_DEVICE_ROW_HEIGHT = 52; +export const DASHBOARD_DEVICE_OVERSCAN = 16; + +type SortDirection = 'asc' | 'desc'; + +const LOCATION_GROUP_VALUE_KEYS = ['location_group', 'group', 'name', 'value', 'label'] as const; +const NUMERIC_SORT_COLUMNS = new Set([ + 'co2', + 'humidity', + 'temperature_c', + 'soil_temperature_c', + 'soil_humidity' +]); + +function toTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readGroupValue(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + + if (!value || typeof value !== 'object') { + return ''; + } + + const record = value as Record; + for (const key of LOCATION_GROUP_VALUE_KEYS) { + const candidate = toTrimmedString(record[key]); + if (candidate) { + return candidate; + } + } + + return ''; +} + +export function getLocationGroupName(location: LocationDto | undefined): string { + if (!location) { + return ''; + } + + const record = location as Record; + return readGroupValue(record.location_group) || readGroupValue(record.group); +} + +function buildLocationGroupById(locations: LocationDto[]): Map { + return new Map( + locations.map( + (location) => [Number(location.location_id), getLocationGroupName(location)] as const + ) + ); +} + +export function buildDashboardTableFilters( + filters: DashboardDeviceFilters +): Record { + return { + ...(filters.group ? { group: [filters.group] } : {}), + ...(filters.locationGroup ? { locationGroup: [filters.locationGroup] } : {}), + ...(filters.location ? { location: [filters.location] } : {}) + }; +} + + +export function filterDashboardDevices( + devices: IDevice[], + locations: LocationDto[], + filters: DashboardDeviceFilters +): IDevice[] { + const locationGroupById = buildLocationGroupById(locations); + + return devices.filter((device) => { + if (filters.group && device.group !== filters.group) { + return false; + } + + if (filters.location && String(device.location_id) !== filters.location) { + return false; + } + + if ( + filters.locationGroup && + locationGroupById.get(Number(device.location_id)) !== filters.locationGroup + ) { + return false; + } + + return true; + }); +} + +export function listDashboardDevices( + devices: IDevice[], + locations: LocationDto[], + filters: DashboardDeviceFilters, + search = '' +): IDevice[] { + return searchDashboardDevices(filterDashboardDevices(devices, locations, filters), search); +} + +export function countDashboardDevices( + devices: IDevice[], + locations: LocationDto[], + filters: DashboardDeviceFilters +): number { + return filterDashboardDevices(devices, locations, filters).length; +} + +function searchDashboardDevices(devices: IDevice[], search: string): IDevice[] { + const term = search.trim().toLowerCase(); + if (!term) { + return devices; + } + + return devices.filter((device) => + [device.name, device.dev_eui, device.location_name].some((value) => + value.toLowerCase().includes(term) + ) + ); +} + +function getSortValue(device: IDevice, column: string): number | string { + if (column === 'created_at') { + return device.created_at instanceof Date + ? device.created_at.getTime() + : new Date(device.created_at).getTime(); + } + + const value = (device as unknown as Record)[column]; + if (NUMERIC_SORT_COLUMNS.has(column)) { + return typeof value === 'number' && Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY; + } + + if (typeof value === 'number') { + return value; + } + + return value != null ? String(value) : ''; +} + +function compareSortValues( + left: number | string, + right: number | string, + direction: SortDirection +): number { + const order = direction === 'asc' ? 1 : -1; + + if (typeof left === 'number' && typeof right === 'number') { + return (left - right) * order; + } + + return ( + left + .toString() + .localeCompare(right.toString(), undefined, { numeric: true, sensitivity: 'base' }) * order + ); +} + +function sortDashboardDevices(devices: IDevice[], query: CwTableQuery): IDevice[] { + if (!query.sort) { + return devices; + } + + const { column, direction } = query.sort; + return [...devices].sort((left, right) => + compareSortValues(getSortValue(left, column), getSortValue(right, column), direction) + ); +} + +export function queryDashboardDevices( + devices: IDevice[], + locations: LocationDto[], + filters: DashboardDeviceFilters, + query: CwTableQuery +): CwTableResult { + const filteredDevices = filterDashboardDevices(devices, locations, filters); + const searchedDevices = searchDashboardDevices(filteredDevices, query.search); + const sortedDevices = sortDashboardDevices(searchedDevices, query); + const start = (query.page - 1) * query.pageSize; + const end = start + query.pageSize; + + return { + rows: sortedDevices.slice(start, end), + total: sortedDevices.length + }; +} diff --git a/src/lib/components/demo/DragDropDemo.svelte b/src/lib/components/demo/DragDropDemo.svelte deleted file mode 100644 index c5fe9f41..00000000 --- a/src/lib/components/demo/DragDropDemo.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - -
    -

    Drag & Drop Demo

    - -
    - -
    - -
    -

    DataRowItems (drag by red handle):

    - - {#each devices as device, index (device.dev_eui)} - - {/each} -
    - -
    -

    Instructions:

    -
      -
    • • Enable the checkbox above to activate drag & drop
    • -
    • • Click and drag the colored bar on the left of each item
    • -
    • • Drop the item at a new position to reorder
    • -
    • • Check the console for reorder events
    • -
    -
    - -
    -

    Current Order:

    -
      - {#each devices as device, index} -
    1. {index + 1}. {device.name}
    2. - {/each} -
    -
    -
    diff --git a/src/lib/components/devices/ExportButton.svelte b/src/lib/components/devices/ExportButton.svelte deleted file mode 100644 index c712b59a..00000000 --- a/src/lib/components/devices/ExportButton.svelte +++ /dev/null @@ -1,231 +0,0 @@ - - -{#if showDatePicker} - -{:else} - -{/if} - - diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.css b/src/lib/components/displays/AirDisplay/AirDisplay.css new file mode 100644 index 00000000..5fa1bf26 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/AirDisplay.css @@ -0,0 +1,50 @@ +.air-display { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; + } + + .air-display__last-updated { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.875rem; + /* color: var(--cw-text-muted); */ + } + + .kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + } + + .kpi-value { + margin: 0 0 0.75rem; + font-size: clamp(1.45rem, 2.1vw, 2rem); + font-weight: 700; + color: var(--cw-text-primary); + } + + .kpi-value span { + margin-left: 0.35rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--cw-text-muted); + } + + .chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + } + + .chart-grid__item { + min-width: 0; + } + + @media (max-width: 1024px) { + .chart-grid { + grid-template-columns: 1fr; + } + } \ No newline at end of file diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.svelte b/src/lib/components/displays/AirDisplay/AirDisplay.svelte new file mode 100644 index 00000000..b9cd2c6d --- /dev/null +++ b/src/lib/components/displays/AirDisplay/AirDisplay.svelte @@ -0,0 +1,467 @@ + + +
    +
    + + + {#if hasCo2} + Math.min(min, Number(row.co2) || 0), Infinity), + max: historicalData.reduce((max, row) => Math.max(max, Number(row.co2) || 0), -Infinity), + avg: + historicalData.reduce((sum, row) => sum + (Number(row.co2) || 0), 0) / + historicalData.length, + median: (() => { + const co2s = historicalData.map((row) => Number(row.co2) || 0).sort((a, b) => a - b); + const mid = Math.floor(co2s.length / 2); + return co2s.length % 2 !== 0 ? co2s[mid] : (co2s[mid - 1] + co2s[mid]) / 2; + })(), + stdDev: (() => { + const co2s = historicalData.map((row) => Number(row.co2) || 0); + const mean = co2s.reduce((sum, value) => sum + value, 0) / co2s.length; + const variance = + co2s.reduce((sum, value) => sum + (value - mean) ** 2, 0) / co2s.length; + return Math.sqrt(variance); + })(), + count: historicalData.length, + lastReading: + historicalData.length > 0 + ? Number(historicalData.at(0)?.co2) || 0 + : 0, + trend: 'up' + }} + unit="ppm" + accentColor="purple" + /> + {/if} +
    + + {#if !loading && rows.length > 0} +
    +
    + + + +
    + +
    + + + +
    +
    + + + {#key tableKey} + + {#snippet cell(row: AirRow, col: CwColumnDef, defaultValue: string)} +
    + {#if col.key === 'created_at'} + {new Date(row.created_at).toLocaleString()} + {:else if col.key === 'temperature_c'} + {row.temperature_c.toFixed(2)} °C + {:else if col.key === 'humidity'} + {row.humidity.toFixed(2)} % + {:else if col.key === 'co2'} + {row.co2} ppm + {:else if col.key === 'alerts'} + {#if row.alerts && row.alerts.length > 0} + ❌ + {:else} + + {/if} + {:else} + {defaultValue} + {/if} +
    + {/snippet} + {#snippet rowActions(row: AirRow)} + handleNoteSaved(row, note)} + /> + {#if row.cw_air_annotations && row.cw_air_annotations.length > 0} + + {/if} + {/snippet} +
    + {/key} +
    + {:else if !loading} + +

    {m.display_no_air_quality_data_selected_range()}

    +
    + {/if} +
    diff --git a/src/lib/components/displays/AirDisplay/dialogs/notes-create-dialog.svelte b/src/lib/components/displays/AirDisplay/dialogs/notes-create-dialog.svelte new file mode 100644 index 00000000..ee5eadf4 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/dialogs/notes-create-dialog.svelte @@ -0,0 +1,149 @@ + + + (open = true)}> + + + + (open = false)} + title={m.display_add_note_title({ createdAt: formatDateTime(row.created_at) })} +> + + +

    {noteText.length}/500

    + + {#snippet actions()} + (open = false)}> + + {m.action_cancel()} + + handleSaveNote()} + > + + {m.display_save_note()} + + {/snippet} +
    + + diff --git a/src/lib/components/displays/AirDisplay/dialogs/notes-view-dialog.svelte b/src/lib/components/displays/AirDisplay/dialogs/notes-view-dialog.svelte new file mode 100644 index 00000000..8be03f00 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/dialogs/notes-view-dialog.svelte @@ -0,0 +1,28 @@ + + + (open = true)}> + + + + (open = false)} + title={m.display_view_notes_title({ createdAt: formatDateTime(row.created_at) })} +> + {#each row.cw_air_annotations as note (note.id)} +

    {formatDateTime(note.created_at)} - {note.note}

    + {/each} + {#snippet actions()} + (open = false)}>{m.action_cancel()} + {/snippet} +
    diff --git a/src/lib/components/displays/AirDisplay/interfaces/AirRow.interface.ts b/src/lib/components/displays/AirDisplay/interfaces/AirRow.interface.ts new file mode 100644 index 00000000..168b9698 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/interfaces/AirRow.interface.ts @@ -0,0 +1,13 @@ +import type { RuleDto } from "$lib/api/api.dtos"; +import type { Note } from "./note.interface"; + +export interface AirRow { + id: string; + created_at: string; + temperature_c: number; + humidity: number; + co2: number; + dev_eui: string; + cw_air_annotations?: Note[] | null; // For rows that have notes, this will be an array of notes; otherwise undefined + alerts: RuleDto[]; // For rows that have triggered rules, this will be an array of rules; otherwise an empty array +} \ No newline at end of file diff --git a/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts b/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts new file mode 100644 index 00000000..7b169821 --- /dev/null +++ b/src/lib/components/displays/AirDisplay/interfaces/note.interface.ts @@ -0,0 +1,5 @@ +export interface Note { + id: string; + created_at: string; + note: string; +} \ No newline at end of file diff --git a/src/lib/components/displays/DefaultDisplay.svelte b/src/lib/components/displays/DefaultDisplay.svelte new file mode 100644 index 00000000..43059d00 --- /dev/null +++ b/src/lib/components/displays/DefaultDisplay.svelte @@ -0,0 +1,82 @@ + + + +
    + + + + {#if loading} +

    {m.display_loading_data()}

    + {:else if historicalData.length === 0} +

    {m.display_no_data_selected_range()}

    + {:else} +
    + + + + {#each columns as col (col)} + + {/each} + + + + {#each historicalData.slice(0, 100) as row, i (i)} + + {#each columns as col (col)} + + {/each} + + {/each} + +
    {col}
    {row[col] ?? '—'}
    +
    + {/if} +
    +
    + + diff --git a/src/lib/components/displays/PowerDisplay/PowerDisplay.svelte b/src/lib/components/displays/PowerDisplay/PowerDisplay.svelte new file mode 100644 index 00000000..61469fee --- /dev/null +++ b/src/lib/components/displays/PowerDisplay/PowerDisplay.svelte @@ -0,0 +1,116 @@ + + + +
    + + {#if latestData} +
    + {#each numericColumns as col (col)} + +

    {Number(latestData[col] ?? 0).toFixed(2)}

    +
    + {/each} +
    + {/if} + + {#if loading} + +

    {m.display_loading_power_data()}

    +
    + {:else if historicalData.length === 0} + +

    {m.display_no_power_data_selected_range()}

    +
    + {:else} + + +
    + + + + {#each columns as col (col)} + + {/each} + + + + {#each historicalData.slice(0, 100) as row, i (i)} + + {#each columns as col (col)} + + {/each} + + {/each} + +
    {col}
    {row[col] ?? '—'}
    +
    +
    + {/if} +
    + + diff --git a/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte new file mode 100644 index 00000000..3872e05d --- /dev/null +++ b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte @@ -0,0 +1,576 @@ + + + +
    + +
    + +
    + + +
    + {#if relayOnePendingState} +
    + +

    {m.devices_relay_waiting_for_confirmation()}

    + {#if relayOnePendingState.verifyAt} +

    + {m.devices_relay_confirmation_checking_again_in()} + + + +

    + {/if} +
    + {:else if latestRelay1 === null} +

    {m.devices_relay_state_unknown()}

    + {/if} +
    + handleRelayCommand(1, 'on')} + > + {m.devices_relay_turn_on()} + + handleRelayCommand(1, 'off')} + > + {m.devices_relay_turn_off()} + +
    +
    + + +
    + + +
    + {#if relayTwoPendingState} +
    + +

    {m.devices_relay_waiting_for_confirmation()}

    + {#if relayTwoPendingState.verifyAt} +

    + {m.devices_relay_confirmation_checking_again_in()} + + + +

    + {/if} +
    + {:else if latestRelay2 === null} +

    {m.devices_relay_state_unknown()}

    + {/if} +
    + handleRelayCommand(2, 'on')} + > + {m.devices_relay_turn_on()} + + handleRelayCommand(2, 'off')} + > + {m.devices_relay_turn_off()} + +
    +
    +
    + + +
    +

    {m.devices_relay_pulse_description()}

    +
    + 2} + /> +
    + handleTimedOnCommand(1)} + > + {m.devices_relay_pulse_action({ relay: m.display_relay_one() })} + + handleTimedOnCommand(2)} + > + {m.devices_relay_pulse_action({ relay: m.display_relay_two() })} + +
    +
    +
    +
    + + {#if lastUpdate} +

    {m.display_last_updated()}: {formatDateTime(lastUpdate)}

    + {/if} + + {#if controlNotice} +

    {controlNotice}

    + {/if} + + {#if !loading && rows.length > 0} + + + {#snippet cell( + row: RelayTelemetryRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'created_at'} + {formatDateTime(row.created_at)} + {:else if col.key === 'relay_1' || col.key === 'relay_2'} + + {:else} + {defaultValue} + {/if} + {/snippet} + + + {:else if !loading} + +

    {m.display_no_relay_history()}

    +
    + {/if} +
    + + diff --git a/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte.spec.ts b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte.spec.ts new file mode 100644 index 00000000..d4c6a6c6 --- /dev/null +++ b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte.spec.ts @@ -0,0 +1,174 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { m } from '$lib/paraglide/messages.js'; +import RelayDisplay from './RelayDisplay.svelte'; +import { MIN_RELAY_PULSE_DURATION_SECONDS } from '$lib/devices/relay-control'; +import type { PendingRelayStates } from '$lib/devices/relay-types'; + +function renderRelayDisplay( + options: { + authToken?: string | null; + historicalData?: Record[]; + latestData?: Record | null; + pendingRelayStates?: PendingRelayStates; + permissionLevel?: number; + queueRelayCommand?: ( + relay: 1 | 2, + targetState: 'off' | 'on', + durationSeconds?: number + ) => Promise; + } = {} +) { + const component = mount(RelayDisplay, { + target: document.body, + props: { + authToken: options.authToken ?? 'jwt-token', + historicalData: options.historicalData ?? [], + latestData: options.latestData ?? null, + loading: false, + pendingRelayStates: options.pendingRelayStates ?? {}, + permissionLevel: options.permissionLevel ?? 1, + queueRelayCommand: options.queueRelayCommand ?? vi.fn(async () => {}) + } + }); + + flushSync(); + + return component; +} + +function getRelayButtons(): HTMLButtonElement[] { + return [...document.body.querySelectorAll('button')]; +} + +function getButtonByLabel(label: string): HTMLButtonElement | null { + return ( + [...document.body.querySelectorAll('button')].find((button) => + button.textContent?.includes(label) + ) ?? null + ); +} + +afterEach(async () => { + document.body.innerHTML = ''; +}); + +describe('RelayDisplay', () => { + it('renders unknown relay state messaging when no confirmed telemetry exists', async () => { + const component = renderRelayDisplay(); + + expect(document.body.textContent).toContain(m.devices_relay_state_unknown()); + + await unmount(component); + }); + + it('locks all relay actions while any relay verification is pending', async () => { + const component = renderRelayDisplay({ + pendingRelayStates: { + 1: { + requestedState: 'on', + verifyAt: '2026-04-05T10:00:15.000Z' + } + } + }); + + const [relayOneTurnOn, relayOneTurnOff, relayTwoTurnOn, relayTwoTurnOff] = getRelayButtons(); + const pulseRelayOneButton = getButtonByLabel( + m.devices_relay_pulse_action({ relay: m.display_relay_one() }) + ); + const pulseRelayTwoButton = getButtonByLabel( + m.devices_relay_pulse_action({ relay: m.display_relay_two() }) + ); + + expect(relayOneTurnOn.disabled).toBe(true); + expect(relayOneTurnOff.disabled).toBe(true); + expect(relayOneTurnOn.getAttribute('aria-pressed')).toBe('true'); + expect(relayOneTurnOff.getAttribute('aria-pressed')).toBe('false'); + expect(relayTwoTurnOn.disabled).toBe(true); + expect(relayTwoTurnOff.disabled).toBe(true); + expect(pulseRelayOneButton?.disabled).toBe(true); + expect(pulseRelayTwoButton?.disabled).toBe(true); + expect(document.body.textContent).toContain(m.devices_relay_confirmation_checking_again_in()); + + await unmount(component); + }); + + it('shows the optimistic on selection in the manual controls for timed-on confirmations', async () => { + const component = renderRelayDisplay({ + latestData: { + created_at: '2026-04-05T10:00:00.000Z', + id: 'relay-row-initial', + relay_1: false, + relay_2: false + }, + pendingRelayStates: { + 1: { + requestedState: 'on', + verifyAt: '2026-04-05T10:00:15.000Z' + } + } + }); + + const [relayOneTurnOn, relayOneTurnOff] = getRelayButtons(); + + expect(relayOneTurnOn.getAttribute('aria-pressed')).toBe('true'); + expect(relayOneTurnOff.getAttribute('aria-pressed')).toBe('false'); + expect(relayOneTurnOn.disabled).toBe(true); + expect(relayOneTurnOff.disabled).toBe(true); + + await unmount(component); + }); + + it('disables relay actions when the current user lacks permission', async () => { + const component = renderRelayDisplay({ + permissionLevel: 3 + }); + + const [relayOneTurnOn, relayOneTurnOff] = getRelayButtons(); + + expect(document.body.textContent).toContain(m.devices_relay_controls_requires_permission()); + expect(relayOneTurnOn.disabled).toBe(true); + expect(relayOneTurnOff.disabled).toBe(true); + + await unmount(component); + }); + + it('delegates relay command clicks through the provided callback', async () => { + const queueRelayCommand = vi.fn(async () => {}); + const component = renderRelayDisplay({ + queueRelayCommand + }); + + const [relayOneTurnOn] = getRelayButtons(); + relayOneTurnOn.click(); + flushSync(); + + expect(queueRelayCommand).toHaveBeenCalledWith(1, 'on'); + + await unmount(component); + }); + + it('shows the localized pulse validation message for values below the 15 second minimum', async () => { + const component = renderRelayDisplay(); + const inputs = [...document.body.querySelectorAll('input')]; + const durationInput = inputs.at(-1) ?? null; + + expect(document.body.textContent).toContain(m.devices_relay_pulse_panel_title()); + expect(document.body.textContent).toContain(m.devices_relay_pulse_description()); + expect(durationInput).not.toBeNull(); + + durationInput!.value = String(MIN_RELAY_PULSE_DURATION_SECONDS - 1); + durationInput!.dispatchEvent(new Event('input', { bubbles: true })); + durationInput!.dispatchEvent(new Event('change', { bubbles: true })); + flushSync(); + + expect(document.body.textContent).toContain( + m.devices_relay_pulse_duration_error({ + maxSeconds: '4294967', + minSeconds: String(MIN_RELAY_PULSE_DURATION_SECONDS) + }) + ); + + await unmount(component); + }); +}); diff --git a/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte b/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte new file mode 100644 index 00000000..3469faee --- /dev/null +++ b/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte @@ -0,0 +1,229 @@ + + + +
    + +
    + + + + + +

    {latest.ec.toFixed(0)}µS/cm

    +
    + + +

    {latest.ph.toFixed(1)}

    +
    +
    + + {#if !loading && rows.length > 0} + + + + + + + {#snippet cell(row: SoilRow, col: CwColumnDef, defaultValue: string)} + {#if col.key === 'created_at'} + {new Date(row.created_at).toLocaleString()} + {:else if col.key === 'temperature_c'} + {row.temperature_c.toFixed(2)} °C + {:else if col.key === 'moisture'} + {row.moisture.toFixed(2)} % + {:else if col.key === 'ec'} + {row.ec.toFixed(0)} µS/cm + {:else if col.key === 'ph'} + {row.ph.toFixed(2)} + {:else} + {defaultValue} + {/if} + {/snippet} + + + {:else if !loading} + +

    {m.display_no_soil_data_selected_range()}

    +
    + {/if} +
    + + diff --git a/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte new file mode 100644 index 00000000..bd475e9a --- /dev/null +++ b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte @@ -0,0 +1,1675 @@ + + +
    +
    + + {#if loading && !currentHourRow} +
    + + {m.traffic_loading_current_traffic()} +
    + {:else if currentHourRow} +

    + {formatNumber(currentHourRow.total_traffic)} + {formatHour(currentHourRow.traffic_hour)} +

    + +
    + +
    + + {#if trafficMetricKeys.length > 0} +
    + {#each trafficMetricKeys as key (key)} +
    + {metricLabel(key)} + {formatTrafficValue(currentHourRow[key])} +
    + {/each} +
    + {/if} + {:else} +

    {m.traffic_no_data_today()}

    + {/if} +
    + + 0 ? m.traffic_today_only() : m.traffic_no_traffic_today()} + elevated + > +

    + {formatNumber(selectedPeriodTotal)} + {m.traffic_tracked_hours_count({ count: formatNumber(hourlyRows.length) })} +

    + +
    + + +
    + + {#if trafficMetricKeys.length > 0} +
    + {#each trafficMetricKeys as key (key)} +
    + {metricLabel(key)} + {formatTrafficValue(selectedPeriodMetricTotals[key])} +
    + {/each} +
    + {/if} +
    + + + {#if selectedWeatherLoading && !currentDaySummary} +
    + + {m.traffic_loading_weather()} +
    + {:else if currentDaySummary} +
    +
    +

    + {weatherEmoji(currentDaySummary.weatherCode)} + {formatCompactTemperature(currentDaySummary.temperatureHighC)} +

    +

    + {m.traffic_low_temperature({ + value: formatCompactTemperature(currentDaySummary.temperatureLowC) + })} +

    +
    + +

    + {weatherEmoji(currentDaySummary.weatherCode)} + {currentDaySummary.weatherLabel} +

    + +
    + + + +
    + +

    {currentDaySummary.weatherSummary}

    +
    + {:else if selectedWeatherError} +

    {selectedWeatherError}

    + {:else} +

    {m.traffic_weather_unavailable()}

    + {/if} +
    +
    + + +
    + + {csvDownloading ? m.traffic_preparing_csv() : m.traffic_download_hourly_csv()} + +
    + + {#if loading && hourlyRows.length === 0} +
    + + {m.traffic_fetching_traffic_data()} +
    + {:else if hourlyRows.length === 0} +

    {m.traffic_no_data_today()}

    + {:else} + + {#snippet cell( + row: HourlyTrafficRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'traffic_hour'} + {formatHour(row.traffic_hour)} + {:else if col.key === 'total_traffic' || col.key === 'samples'} + {formatTrafficValue(row[col.key])} + {:else if col.key === 'weather_summary'} + {row.weather_summary} + {:else} + {formatTrafficValue(row[col.key] ?? defaultValue)} + {/if} + {/snippet} + + {/if} +
    + + +
    + + + + {#if calendarLoading} + + {/if} + + {#if calendarError} + + {:else if !calendarLoading && calendarDailySummaries.length === 0} + + {/if} +
    + + + {#snippet dayContent(date)} + {@const summary = calendarSummaryByKey[toDayKey(date)]} + {#if summary} +
    +
    +
    +

    + {formatCompactTemperature(summary.temperatureHighC)} +

    +

    + {m.traffic_low_temperature({ + value: formatCompactTemperature(summary.temperatureLowC) + })} +

    +
    + +

    + {weatherEmoji(summary.weatherCode)} + {summary.weatherLabel} +

    +

    + 💧 {formatMeasurement(summary.precipitationMm, 'mm', 1)} +

    +
    + +
    +

    + {m.traffic_calendar_total({ count: formatNumber(summary.totalTraffic) })} +

    + + {#if calendarTrafficMetricKeys.length > 0} +
    + {#each calendarTrafficMetricKeys as key (key)} +

    + {metricLabel(key)}: {formatTrafficValue(summary.classTotals[key])} +

    + {/each} +
    + {:else} +

    {m.traffic_no_class_data_recorded()}

    + {/if} +
    +
    + {/if} + {/snippet} +
    +
    +
    + + diff --git a/src/lib/components/displays/WaterDisplay/WaterDisplay.svelte b/src/lib/components/displays/WaterDisplay/WaterDisplay.svelte new file mode 100644 index 00000000..7a129cc7 --- /dev/null +++ b/src/lib/components/displays/WaterDisplay/WaterDisplay.svelte @@ -0,0 +1,188 @@ + + + +
    + +
    + +

    {latest.temperature_c.toFixed(1)}°C

    +
    + + +

    {latest.depth_cm.toFixed(1)}cm

    +
    + + +

    {latest.pressure.toFixed(1)}

    +
    + + +

    {latest.spo2.toFixed(1)}

    +
    +
    + + {#if !loading && rows.length > 0} + + + + + + + {#snippet cell(row: WaterRow, col: CwColumnDef, defaultValue: string)} + {#if col.key === 'created_at'} + {new Date(row.created_at).toLocaleString()} + {:else if col.key === 'temperature_c'} + {row.temperature_c.toFixed(2)} °C + {:else if col.key === 'depth_cm'} + {row.depth_cm.toFixed(1)} cm + {:else} + {defaultValue} + {/if} + {/snippet} + + + {:else if !loading} + +

    {m.display_no_water_data_selected_range()}

    +
    + {/if} +
    + + diff --git a/src/lib/components/displays/index.ts b/src/lib/components/displays/index.ts new file mode 100644 index 00000000..92975ba7 --- /dev/null +++ b/src/lib/components/displays/index.ts @@ -0,0 +1,15 @@ +/** + * Barrel export for device display components. + * + * These are typically NOT imported directly — the registry in + * `$lib/config/deviceTables.ts` uses dynamic `import()` to lazy-load them. + * This index exists for convenience in tests or stories. + */ +export { default as AirDisplay } from './AirDisplay/AirDisplay.svelte'; +export { default as SoilDisplay } from './SoilDisplay/SoilDisplay.svelte'; +export { default as TrafficDisplay } from './TrafficDisplay/TrafficDisplay.svelte'; +export { default as WaterDisplay } from './WaterDisplay/WaterDisplay.svelte'; +export { default as RelayDisplay } from './RelayDisplay/RelayDisplay.svelte'; +export { default as PowerDisplay } from './PowerDisplay/PowerDisplay.svelte'; +export { default as DefaultDisplay } from './DefaultDisplay.svelte'; + \ No newline at end of file diff --git a/src/lib/components/global/Breadcrumbs.svelte b/src/lib/components/global/Breadcrumbs.svelte deleted file mode 100644 index 267bdf30..00000000 --- a/src/lib/components/global/Breadcrumbs.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - - -{#snippet breadcrumb({ path, label, showArrow = true }: BreadcrumbProps)} - {#if showArrow} - - - - {/if} - {label} -{/snippet} - - diff --git a/src/lib/components/layout/AppActionRow.svelte b/src/lib/components/layout/AppActionRow.svelte new file mode 100644 index 00000000..cf867378 --- /dev/null +++ b/src/lib/components/layout/AppActionRow.svelte @@ -0,0 +1,38 @@ + + +
    + {#if children} + {@render children()} + {/if} +
    + + diff --git a/src/lib/components/layout/AppFormStack.svelte b/src/lib/components/layout/AppFormStack.svelte new file mode 100644 index 00000000..5581129c --- /dev/null +++ b/src/lib/components/layout/AppFormStack.svelte @@ -0,0 +1,42 @@ + + +
    + {#if children} + {@render children()} + {/if} +
    + + diff --git a/src/lib/components/layout/AppNotice.svelte b/src/lib/components/layout/AppNotice.svelte new file mode 100644 index 00000000..53b01439 --- /dev/null +++ b/src/lib/components/layout/AppNotice.svelte @@ -0,0 +1,94 @@ + + +
    + {#if title} +

    {title}

    + {/if} + + {#if children} +
    + {@render children()} +
    + {/if} +
    + + diff --git a/src/lib/components/layout/AppPage.svelte b/src/lib/components/layout/AppPage.svelte new file mode 100644 index 00000000..567ea3b9 --- /dev/null +++ b/src/lib/components/layout/AppPage.svelte @@ -0,0 +1,49 @@ + + +
    +
    + {#if children} + {@render children()} + {/if} +
    +
    + + diff --git a/src/lib/components/layout/AppSection.svelte b/src/lib/components/layout/AppSection.svelte new file mode 100644 index 00000000..00320939 --- /dev/null +++ b/src/lib/components/layout/AppSection.svelte @@ -0,0 +1,34 @@ + + +
    + {#if children} + {@render children()} + {/if} +
    + + diff --git a/src/lib/components/layout/index.ts b/src/lib/components/layout/index.ts new file mode 100644 index 00000000..b505f68c --- /dev/null +++ b/src/lib/components/layout/index.ts @@ -0,0 +1,5 @@ +export { default as AppActionRow } from './AppActionRow.svelte'; +export { default as AppFormStack } from './AppFormStack.svelte'; +export { default as AppNotice } from './AppNotice.svelte'; +export { default as AppPage } from './AppPage.svelte'; +export { default as AppSection } from './AppSection.svelte'; diff --git a/src/lib/components/maps/leaflet/LeafletMap.svelte b/src/lib/components/maps/leaflet/LeafletMap.svelte deleted file mode 100644 index e04db2cc..00000000 --- a/src/lib/components/maps/leaflet/LeafletMap.svelte +++ /dev/null @@ -1,309 +0,0 @@ - - - - -
    - - diff --git a/src/lib/components/maps/leaflet/MapToolbar.svelte b/src/lib/components/maps/leaflet/MapToolbar.svelte deleted file mode 100644 index f783d543..00000000 --- a/src/lib/components/maps/leaflet/MapToolbar.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/lib/components/maps/leaflet/MarkerPopup.svelte b/src/lib/components/maps/leaflet/MarkerPopup.svelte deleted file mode 100644 index e09bd4d5..00000000 --- a/src/lib/components/maps/leaflet/MarkerPopup.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - -
    - - -
    - -
    - {count} -
    diff --git a/src/lib/components/maps/leaflet/markers.js b/src/lib/components/maps/leaflet/markers.js deleted file mode 100644 index a57d41ec..00000000 --- a/src/lib/components/maps/leaflet/markers.js +++ /dev/null @@ -1,2 +0,0 @@ -// Icons from heroicons.dev -export const library = ``; \ No newline at end of file diff --git a/src/lib/components/status/AppStatusDot.svelte b/src/lib/components/status/AppStatusDot.svelte new file mode 100644 index 00000000..aea62a9f --- /dev/null +++ b/src/lib/components/status/AppStatusDot.svelte @@ -0,0 +1,204 @@ + + + + + {#if showLabel} + {statusLabel} + {/if} + + + diff --git a/src/lib/components/theme/ThemeModeSelector.svelte b/src/lib/components/theme/ThemeModeSelector.svelte deleted file mode 100644 index 1dd609b4..00000000 --- a/src/lib/components/theme/ThemeModeSelector.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
    - - - - -
    - - diff --git a/src/lib/components/theme/ThemeToggle.svelte b/src/lib/components/theme/ThemeToggle.svelte deleted file mode 100644 index 69fc5d9c..00000000 --- a/src/lib/components/theme/ThemeToggle.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/src/lib/components/theme/theme.svelte.ts b/src/lib/components/theme/theme.svelte.ts deleted file mode 100644 index f5ef97b7..00000000 --- a/src/lib/components/theme/theme.svelte.ts +++ /dev/null @@ -1,97 +0,0 @@ -// theme.svelte.ts - Theme state management using Svelte 5 runes -// This follows the latest Svelte 5 documentation - -/** - * Initialize theme based on localStorage or system preference - * @returns {boolean} True if dark mode should be active - */ -// NOTE: This legacy module is now a thin wrapper over the central themeStore. -// It is kept to avoid breaking existing imports (ThemeToggle etc.). -import { themeStore, setThemeMode, toggleExplicitLightDark } from '$lib/stores/theme'; -import { get } from 'svelte/store'; - -function initializeTheme(): boolean { - // Derive initial from store (store already handled localStorage + system) - return get(themeStore).effective === 'dark'; -} - -// Create private module-level state variables with default values for SSR -const _theme = $state({ - // Default to true for dark mode during SSR to avoid flash of light content - // This will be immediately overridden by client-side initialization - darkMode: true, - systemPrefersDark: false -}); - -// Initialize theme state on client-side only -if (typeof window !== 'undefined') { - // Run immediately for initial setup - _theme.darkMode = initializeTheme(); - _theme.systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - - // Apply theme immediately - applyTheme(_theme.darkMode); - //console.log('Theme initialized from localStorage:', _theme.darkMode ? 'dark' : 'light'); - - // Add a MutationObserver to monitor for any class changes on html element - // This will help detect if something else is modifying the theme - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName === 'class') { - const htmlElement = document.documentElement; - const hasDarkClass = htmlElement.classList.contains('dark'); - - // If there's a mismatch between state and DOM, fix it - if (hasDarkClass !== _theme.darkMode) { - //console.log('Theme mismatch detected, resynchronizing'); - // Update DOM to match our state - applyTheme(_theme.darkMode); - } - } - }); - }); - - // Start observing the document with the configured parameters - observer.observe(document.documentElement, { attributes: true }); - - // Keep legacy _theme state in sync with central store so getDarkMode() reflects selector changes - themeStore.subscribe((v) => { - _theme.darkMode = v.effective === 'dark'; - }); -} - -// Export getter functions instead of the state directly -export function getDarkMode(): boolean { - return _theme.darkMode; -} - -export function getSystemPrefersDark(): boolean { - return _theme.systemPrefersDark; -} - -// Function to update the DOM and localStorage when theme changes -// This is called by components, not at the module level -export function applyTheme(isDark: boolean): void { - // Delegate to theme store explicit mode set - setThemeMode(isDark ? 'dark' : 'light'); -} - -/** - * Toggle between light and dark mode - */ -export function toggleTheme(): void { - toggleExplicitLightDark(); - _theme.darkMode = get(themeStore).effective === 'dark'; -} - -/** - * Set the theme explicitly - * @param {boolean} dark - True to set dark mode, false for light mode - */ -export function setTheme(dark: boolean): void { - setThemeMode(dark ? 'dark' : 'light'); - _theme.darkMode = get(themeStore).effective === 'dark'; -} - -// Set up listener for system preference changes -// System preference changes handled centrally by themeStore now; no duplicate listeners here. diff --git a/src/lib/components/ui/base/Button.svelte b/src/lib/components/ui/base/Button.svelte deleted file mode 100644 index 1d89f6ed..00000000 --- a/src/lib/components/ui/base/Button.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/src/lib/components/ui/base/Card.svelte b/src/lib/components/ui/base/Card.svelte deleted file mode 100644 index b6f698cf..00000000 --- a/src/lib/components/ui/base/Card.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -
    - {@render children?.()} -
    diff --git a/src/lib/components/ui/base/Collapse.svelte b/src/lib/components/ui/base/Collapse.svelte deleted file mode 100644 index a8993f6c..00000000 --- a/src/lib/components/ui/base/Collapse.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -
    -
    - {@render trigger?.()} -
    - {#if open} -
    {@render children?.()}
    - {/if} -
    diff --git a/src/lib/components/ui/base/Duration.svelte b/src/lib/components/ui/base/Duration.svelte deleted file mode 100644 index 107f6c17..00000000 --- a/src/lib/components/ui/base/Duration.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{value} diff --git a/src/lib/components/ui/base/Icon.svelte b/src/lib/components/ui/base/Icon.svelte deleted file mode 100644 index 19cc8c41..00000000 --- a/src/lib/components/ui/base/Icon.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/src/lib/config/deviceTables.ts b/src/lib/config/deviceTables.ts new file mode 100644 index 00000000..91c10e7d --- /dev/null +++ b/src/lib/config/deviceTables.ts @@ -0,0 +1,90 @@ +/** + * Central registry for device data table names and their display components. + * + * ## How to add a new table type + * + * 1. Create a display component in `$lib/components/displays/` that accepts + * {@link DeviceDisplayProps} (see `$lib/interfaces/deviceDisplay.ts`). + * 2. Add an entry to {@link TABLE_REGISTRY} below mapping the table name + * (as stored in `cw_device_type.data_table`) to the component. + * 3. Done — the device page dispatcher will automatically pick it up. + * + * This registry is intentionally synchronous. The route only has a handful of + * displays, and avoiding async component resolution keeps the page render path + * simpler and easier to debug. + */ + +import AirDisplay from '$lib/components/displays/AirDisplay/AirDisplay.svelte'; +import DefaultDisplay from '$lib/components/displays/DefaultDisplay.svelte'; +import PowerDisplay from '$lib/components/displays/PowerDisplay/PowerDisplay.svelte'; +import RelayDisplay from '$lib/components/displays/RelayDisplay/RelayDisplay.svelte'; +import SoilDisplay from '$lib/components/displays/SoilDisplay/SoilDisplay.svelte'; +import TrafficDisplay from '$lib/components/displays/TrafficDisplay/TrafficDisplay.svelte'; +import WaterDisplay from '$lib/components/displays/WaterDisplay/WaterDisplay.svelte'; +import type { DeviceDisplayComponent } from '$lib/interfaces/deviceDisplay'; + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** Maps a `data_table` name to the component that renders that telemetry. */ +export const TABLE_REGISTRY: Record = { + cw_air_data: AirDisplay, + cw_soil_data: SoilDisplay, + cw_traffic2: TrafficDisplay, + traffic_v2: TrafficDisplay, + cw_water_data: WaterDisplay, + cw_relay_data: RelayDisplay, + cw_power_data: PowerDisplay +}; + +/** + * Resolve the display component for a given data-table name. + * + * Returns the `DefaultDisplay` component when the table is unknown or null, + * so every device always has *some* renderable view. + */ +export function resolveDisplayComponent(table: string | null | undefined): DeviceDisplayComponent { + if (!table) { + return DefaultDisplay; + } + + return TABLE_REGISTRY[table] ?? DefaultDisplay; +} + +// --------------------------------------------------------------------------- +// Convenience helpers (kept for backward-compat / guards in shared code) +// --------------------------------------------------------------------------- + +/** All table names known to the registry. */ +export const KNOWN_TABLES = Object.keys(TABLE_REGISTRY); + +/** Tables that contain time-bucketed traffic / counting data. */ +export const TRAFFIC_TABLES = ['cw_traffic2', 'traffic_v2'] as const; + +/** Tables that represent relay / actuator state. */ +export const RELAY_TABLES = ['cw_relay_data'] as const; + +/** Sensor data tables rendered with dedicated or generic display components. */ +export const SENSOR_DATA_TABLES = [ + 'cw_air_data', + 'cw_soil_data', + 'cw_water_data', + 'cw_power_data' +] as const; + +export type TrafficTable = (typeof TRAFFIC_TABLES)[number]; +export type RelayTable = (typeof RELAY_TABLES)[number]; +export type SensorDataTable = (typeof SENSOR_DATA_TABLES)[number]; + +export function isTrafficTable(table: string | null | undefined): table is TrafficTable { + return TRAFFIC_TABLES.includes(table as TrafficTable); +} + +export function isRelayTable(table: string | null | undefined): table is RelayTable { + return RELAY_TABLES.includes(table as RelayTable); +} + +export function hasDataTableView(table: string | null | undefined): boolean { + return table != null && !isTrafficTable(table) && !isRelayTable(table); +} diff --git a/src/lib/devices/relay-control.spec.ts b/src/lib/devices/relay-control.spec.ts new file mode 100644 index 00000000..95e7f20f --- /dev/null +++ b/src/lib/devices/relay-control.spec.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + RELAY_REFRESH_INTERVAL_MS, + RELAY_VERIFICATION_DELAY_MS, + RelayStateManager +} from './relay-control'; + +function createRelayRow(overrides: Record = {}) { + return { + created_at: '2026-04-05T10:00:00.000Z', + id: 'relay-row-1', + relay_1: false, + relay_2: false, + ...overrides + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('RelayStateManager', () => { + it('fetches the current relay state immediately on creation', async () => { + const fetchLatestData = vi.fn(async () => + createRelayRow({ + id: 'relay-row-current', + relay_1: true + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [], + latestData: null + }); + + await Promise.resolve(); + + expect(fetchLatestData).toHaveBeenCalledTimes(1); + expect(manager.getSnapshot().latestData).toMatchObject({ + id: 'relay-row-current', + relay_1: true + }); + + manager.destroy(); + }); + + it('schedules one verification fetch and clears pending when the API matches the requested state', async () => { + vi.useFakeTimers(); + const onVerificationResolved = vi.fn(); + const fetchLatestData = vi.fn(async () => + createRelayRow({ + created_at: '2026-04-05T10:00:15.000Z', + id: 'relay-row-verified', + relay_1: true + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [], + latestData: createRelayRow(), + fetchOnInit: false, + onVerificationResolved + }); + + manager.startVerification(1, 'on'); + expect(manager.getSnapshot().pendingRelayStates[1]).toEqual({ + requestedState: 'on', + verifyAt: expect.any(String) + }); + + await vi.advanceTimersByTimeAsync(RELAY_VERIFICATION_DELAY_MS - 1); + expect(fetchLatestData).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(fetchLatestData).toHaveBeenCalledTimes(1); + expect(manager.getSnapshot().pendingRelayStates[1]).toBeUndefined(); + expect(manager.getSnapshot().latestData).toMatchObject({ + id: 'relay-row-verified', + relay_1: true + }); + expect(onVerificationResolved).toHaveBeenCalledWith({ + matched: true, + relay: 1, + row: expect.objectContaining({ + id: 'relay-row-verified', + relay_1: true + }), + targetState: 'on' + }); + + manager.destroy(); + }); + + it('clears pending and keeps the API-reported state when verification does not match', async () => { + vi.useFakeTimers(); + const onVerificationResolved = vi.fn(); + const fetchLatestData = vi.fn(async () => + createRelayRow({ + created_at: '2026-04-05T10:00:15.000Z', + id: 'relay-row-mismatch', + relay_1: false + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [], + latestData: createRelayRow({ + id: 'relay-row-initial', + relay_1: false + }), + fetchOnInit: false, + onVerificationResolved + }); + + manager.startVerification(1, 'on'); + await vi.advanceTimersByTimeAsync(RELAY_VERIFICATION_DELAY_MS); + + expect(manager.getSnapshot().pendingRelayStates[1]).toBeUndefined(); + expect(manager.getSnapshot().latestData).toMatchObject({ + id: 'relay-row-mismatch', + relay_1: false + }); + expect(onVerificationResolved).toHaveBeenCalledWith({ + matched: false, + relay: 1, + row: expect.objectContaining({ + id: 'relay-row-mismatch', + relay_1: false + }), + targetState: 'on' + }); + + manager.destroy(); + }); + + it('refreshes relay state every 30 seconds to pick up external updates', async () => { + vi.useFakeTimers(); + const fetchLatestData = vi + .fn<() => Promise | null>>() + .mockResolvedValueOnce( + createRelayRow({ + created_at: '2026-04-05T10:00:30.000Z', + id: 'relay-row-refresh', + relay_2: true + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [], + latestData: createRelayRow(), + fetchOnInit: false + }); + + await vi.advanceTimersByTimeAsync(RELAY_REFRESH_INTERVAL_MS); + + expect(fetchLatestData).toHaveBeenCalledTimes(1); + expect(manager.getSnapshot().latestData).toMatchObject({ + id: 'relay-row-refresh', + relay_2: true + }); + + manager.destroy(); + }); + + it('keeps the relay GET result as current state even when historical telemetry has a newer timestamp', async () => { + vi.useFakeTimers(); + const onVerificationResolved = vi.fn(); + const fetchLatestData = vi.fn(async () => + createRelayRow({ + created_at: '2026-04-05T10:00:15.000Z', + id: 'relay-row-current', + relay_1: true + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [ + createRelayRow({ + created_at: '2026-04-05T10:00:30.000Z', + id: 'relay-row-history-newer', + relay_1: false + }) + ], + latestData: createRelayRow({ + created_at: '2026-04-05T10:00:30.000Z', + id: 'relay-row-history-newer', + relay_1: false + }), + fetchOnInit: false, + onVerificationResolved + }); + + manager.startVerification(1, 'on'); + await vi.advanceTimersByTimeAsync(RELAY_VERIFICATION_DELAY_MS); + + expect(manager.getSnapshot().latestData).toMatchObject({ + id: 'relay-row-current', + relay_1: true + }); + expect(manager.getSnapshot().historicalData[0]).toMatchObject({ + id: 'relay-row-history-newer', + relay_1: false + }); + expect(onVerificationResolved).toHaveBeenCalledWith({ + matched: true, + relay: 1, + row: expect.objectContaining({ + id: 'relay-row-current', + relay_1: true + }), + targetState: 'on' + }); + + manager.destroy(); + }); + + it('reuses the in-flight GET when verification and background refresh overlap', async () => { + vi.useFakeTimers(); + let resolveFetch!: (value: Record | null) => void; + const fetchLatestData = vi.fn( + () => + new Promise | null>((resolve) => { + resolveFetch = resolve; + }) + ); + + const manager = new RelayStateManager({ + fetchLatestData, + historicalData: [], + latestData: createRelayRow(), + fetchOnInit: false + }); + + manager.startVerification(1, 'on'); + await vi.advanceTimersByTimeAsync(RELAY_VERIFICATION_DELAY_MS); + expect(fetchLatestData).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(RELAY_REFRESH_INTERVAL_MS); + expect(fetchLatestData).toHaveBeenCalledTimes(1); + + resolveFetch( + createRelayRow({ + created_at: '2026-04-05T10:00:15.000Z', + id: 'relay-row-overlap', + relay_1: true + }) + ); + await vi.advanceTimersByTimeAsync(0); + + expect(manager.getSnapshot().pendingRelayStates[1]).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(RELAY_REFRESH_INTERVAL_MS); + expect(fetchLatestData).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); +}); diff --git a/src/lib/devices/relay-control.ts b/src/lib/devices/relay-control.ts new file mode 100644 index 00000000..bbfe8b0e --- /dev/null +++ b/src/lib/devices/relay-control.ts @@ -0,0 +1,243 @@ +import { + mergeRelayTelemetryRows, + normalizeRelayTelemetryRow, + normalizeRelayTelemetryRows, + relayStateMatchesTarget +} from './relay-telemetry'; +import type { + PendingRelayState, + PendingRelayStates, + RelayNumber, + RelayTargetState, + RelayTelemetryRow +} from './relay-types'; + +export const RELAY_VERIFICATION_DELAY_MS = 15_000; +export const RELAY_REFRESH_INTERVAL_MS = 30_000; +export const MIN_RELAY_PULSE_DURATION_SECONDS = 15; +export const MAX_RELAY_PULSE_DURATION_SECONDS = 4_294_967; + +export interface RelayStateSnapshot { + historicalData: RelayTelemetryRow[]; + latestData: RelayTelemetryRow | null; + pendingRelayStates: PendingRelayStates; +} + +export interface RelayVerificationResult { + matched: boolean; + relay: RelayNumber; + row: RelayTelemetryRow | null; + targetState: RelayTargetState; +} + +interface RelayStateBaseData { + historicalData: Record[]; + latestData: Record | null; +} + +export interface RelayStateManagerOptions extends RelayStateBaseData { + fetchLatestData: (signal?: AbortSignal) => Promise | null>; + onVerificationResolved?: (result: RelayVerificationResult) => void; + fetchOnInit?: boolean; + refreshIntervalMs?: number; + verifyDelayMs?: number; +} + +export type RelayStateListener = (snapshot: RelayStateSnapshot) => void; + +export class RelayStateManager { + private readonly fetchLatestData: RelayStateManagerOptions['fetchLatestData']; + private readonly listeners = new Set(); + private readonly onVerificationResolved?: RelayStateManagerOptions['onVerificationResolved']; + private readonly refreshIntervalMs: number; + private readonly verifyDelayMs: number; + private currentFetch: Promise | null = null; + private currentFetchAbortController: AbortController | null = null; + private refreshTimer: ReturnType | null = null; + private snapshot: RelayStateSnapshot; + private verificationTimers = new Map>(); + + public constructor(options: RelayStateManagerOptions) { + this.fetchLatestData = options.fetchLatestData; + this.onVerificationResolved = options.onVerificationResolved; + this.refreshIntervalMs = options.refreshIntervalMs ?? RELAY_REFRESH_INTERVAL_MS; + this.verifyDelayMs = options.verifyDelayMs ?? RELAY_VERIFICATION_DELAY_MS; + this.snapshot = this.buildSnapshot(options); + this.startBackgroundRefresh(); + + if (options.fetchOnInit !== false) { + void this.refreshLatestData(); + } + } + + public destroy(): void { + for (const relay of [1, 2] as const) { + this.clearVerificationTimer(relay); + } + + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + + this.currentFetchAbortController?.abort(); + this.currentFetchAbortController = null; + this.currentFetch = null; + this.listeners.clear(); + } + + public getSnapshot(): RelayStateSnapshot { + return this.snapshot; + } + + public replaceBaseData(baseData: RelayStateBaseData): void { + const latest = normalizeRelayTelemetryRow(baseData.latestData); + const history = mergeRelayTelemetryRows( + this.snapshot.historicalData, + latest ? [latest] : [], + normalizeRelayTelemetryRows(baseData.historicalData), + this.snapshot.latestData ? [this.snapshot.latestData] : [] + ); + const currentLatestData = this.snapshot.latestData ?? latest ?? history[0] ?? null; + + this.emit({ + historicalData: history, + latestData: currentLatestData + }); + } + + public async refreshLatestData(): Promise { + return await this.fetchAndApplyLatestData(); + } + + public startVerification(relay: RelayNumber, targetState: RelayTargetState): void { + this.clearVerificationTimer(relay); + + const pendingState: PendingRelayState = { + requestedState: targetState, + verifyAt: new Date(Date.now() + this.verifyDelayMs).toISOString() + }; + + this.emit({ + pendingRelayStates: { + ...this.snapshot.pendingRelayStates, + [relay]: pendingState + } + }); + + const timer = setTimeout(() => { + void this.runVerification(relay, pendingState); + }, this.verifyDelayMs); + + this.verificationTimers.set(relay, timer); + } + + public subscribe(listener: RelayStateListener): () => void { + this.listeners.add(listener); + listener(this.snapshot); + + return () => { + this.listeners.delete(listener); + }; + } + + private applyIncomingRow(row: RelayTelemetryRow): RelayTelemetryRow[] { + const history = mergeRelayTelemetryRows([row], this.snapshot.historicalData); + this.emit({ + historicalData: history, + latestData: row + }); + return history; + } + + private buildSnapshot(baseData: RelayStateBaseData): RelayStateSnapshot { + const latest = normalizeRelayTelemetryRow(baseData.latestData); + const history = mergeRelayTelemetryRows( + latest ? [latest] : [], + normalizeRelayTelemetryRows(baseData.historicalData) + ); + + return { + historicalData: history, + latestData: history[0] ?? latest, + pendingRelayStates: {} + }; + } + + private clearVerificationTimer(relay: RelayNumber): void { + const timer = this.verificationTimers.get(relay); + if (timer) { + clearTimeout(timer); + this.verificationTimers.delete(relay); + } + } + + private emit(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) listener(this.snapshot); + } + + private async fetchAndApplyLatestData(): Promise { + if (this.currentFetch) { + return await this.currentFetch; + } + + const abortController = new AbortController(); + this.currentFetchAbortController = abortController; + this.currentFetch = (async () => { + try { + const row = await this.fetchLatestData(abortController.signal); + const normalized = normalizeRelayTelemetryRow(row); + if (normalized) { + this.applyIncomingRow(normalized); + } + return normalized; + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + console.error('[relay-state] fetch failed', { error }); + } + return null; + } finally { + if (this.currentFetchAbortController === abortController) { + this.currentFetchAbortController = null; + } + this.currentFetch = null; + } + })(); + + return await this.currentFetch; + } + + private async runVerification( + relay: RelayNumber, + pendingState: PendingRelayState + ): Promise { + const latestPendingState = this.snapshot.pendingRelayStates[relay]; + if ( + !latestPendingState || + latestPendingState.requestedState !== pendingState.requestedState || + latestPendingState.verifyAt !== pendingState.verifyAt + ) { + return; + } + + const row = await this.fetchAndApplyLatestData(); + const nextPendingRelayStates = { ...this.snapshot.pendingRelayStates }; + delete nextPendingRelayStates[relay]; + this.emit({ pendingRelayStates: nextPendingRelayStates }); + this.clearVerificationTimer(relay); + + this.onVerificationResolved?.({ + matched: relayStateMatchesTarget(row, relay, pendingState.requestedState), + relay, + row, + targetState: pendingState.requestedState + }); + } + + private startBackgroundRefresh(): void { + this.refreshTimer = setInterval(() => { + void this.refreshLatestData(); + }, this.refreshIntervalMs); + } +} diff --git a/src/lib/devices/relay-telemetry.ts b/src/lib/devices/relay-telemetry.ts new file mode 100644 index 00000000..a72a97e9 --- /dev/null +++ b/src/lib/devices/relay-telemetry.ts @@ -0,0 +1,85 @@ +import type { RelayNumber, RelayTargetState, RelayTelemetryRow } from './relay-types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function coerceRelayValue(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (value === 1) return true; + if (value === 0) return false; + return null; +} + +export function normalizeRelayTelemetryRow(value: unknown): RelayTelemetryRow | null { + if (!isRecord(value)) return null; + + const createdAt = + (typeof value.last_update === 'string' && value.last_update.trim()) || + (typeof value.created_at === 'string' && value.created_at.trim()) || + ''; + if (!createdAt) return null; + + const relay1 = coerceRelayValue(value.relay_1); + const relay2 = coerceRelayValue(value.relay_2); + const id = String(value.id ?? value.data_id ?? `${createdAt}:${relay1}:${relay2}`); + + return { ...value, created_at: createdAt, id, relay_1: relay1, relay_2: relay2 }; +} + +export function normalizeRelayTelemetryRows( + values: Record[] +): RelayTelemetryRow[] { + return dedupeAndSort( + values.reduce((acc, value) => { + const row = normalizeRelayTelemetryRow(value); + if (row) acc.push(row); + return acc; + }, []) + ); +} + +function parseTime(value: string | null | undefined): number { + if (!value) return -Infinity; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? -Infinity : parsed; +} + +export function compareRelayCreatedAt( + left: string | null | undefined, + right: string | null | undefined +): number { + return parseTime(left) - parseTime(right); +} + +function dedupeAndSort(rows: RelayTelemetryRow[]): RelayTelemetryRow[] { + const byId = new Map(); + for (const row of rows) byId.set(row.id, row); + return [...byId.values()].sort( + (a, b) => compareRelayCreatedAt(b.created_at, a.created_at) || a.id.localeCompare(b.id) + ); +} + +export function mergeRelayTelemetryRows( + ...collections: Array> +): RelayTelemetryRow[] { + return dedupeAndSort(collections.flat()); +} + +export function getRelayState( + row: RelayTelemetryRow | null | undefined, + relay: RelayNumber +): boolean | null { + if (!row) return null; + return relay === 1 ? row.relay_1 : row.relay_2; +} + +export function relayStateMatchesTarget( + row: RelayTelemetryRow | null | undefined, + relay: RelayNumber, + targetState: RelayTargetState +): boolean { + const value = getRelayState(row, relay); + if (value === null) return false; + return targetState === 'on' ? value : !value; +} diff --git a/src/lib/devices/relay-types.ts b/src/lib/devices/relay-types.ts new file mode 100644 index 00000000..56f369c9 --- /dev/null +++ b/src/lib/devices/relay-types.ts @@ -0,0 +1,24 @@ +export type RelayNumber = 1 | 2; +export type RelayTargetState = 'off' | 'on'; + +export interface PendingRelayState { + requestedState: RelayTargetState; + verifyAt: string; +} + +export type PendingRelayStates = Partial>; + +export interface RelayTelemetryRow extends Record { + created_at: string; + id: string; + relay_1: boolean | null; + relay_2: boolean | null; +} + +export function isRelayNumber(value: unknown): value is RelayNumber { + return value === 1 || value === 2; +} + +export function isRelayTargetState(value: unknown): value is RelayTargetState { + return value === 'off' || value === 'on'; +} diff --git a/src/lib/devices/tti-device-id.ts b/src/lib/devices/tti-device-id.ts new file mode 100644 index 00000000..2f9233f6 --- /dev/null +++ b/src/lib/devices/tti-device-id.ts @@ -0,0 +1,14 @@ +export const TTI_DEVICE_ID_MAX_LENGTH = 36; +export const TTI_DEVICE_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,34}[a-z0-9])?$/; + +export function normalizeTtiDeviceId(value: string | null | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +export function isValidTtiDeviceId(value: string): boolean { + if (value.length === 0 || value.length > TTI_DEVICE_ID_MAX_LENGTH) { + return false; + } + + return TTI_DEVICE_ID_PATTERN.test(value); +} diff --git a/src/lib/dtos/AirDataDto.ts b/src/lib/dtos/AirDataDto.ts deleted file mode 100644 index 100fc34c..00000000 --- a/src/lib/dtos/AirDataDto.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { AirData } from '../models/AirData'; - -/** - * DTO for air data creation - */ -export interface CreateAirDataDto { - /** - * Device EUI - */ - dev_eui: string; - - /** - * Temperature in Celsius - */ - temperature_c?: number; - - /** - * Humidity percentage - */ - humidity?: number; - - /** - * CO level - */ - co?: number; - - /** - * CO2 level - */ - co2?: number; - - /** - * Pressure value - */ - pressure?: number; - - /** - * Light level in lux - */ - lux?: number; - - /** - * Battery level percentage - */ - battery_level?: number; - - /** - * UV index - */ - uv_index?: number; - - /** - * Rainfall amount - */ - rainfall?: number; - - /** - * Wind speed - */ - wind_speed?: number; - - /** - * Wind direction in degrees - */ - wind_direction?: number; - - /** - * Whether smoke is detected - */ - smoke_detected?: boolean; - - /** - * Whether vape is detected - */ - vape_detected?: boolean; - - /** - * Whether this data point is simulated - */ - is_simulated?: boolean; -} - -/** - * DTO for air data responses - */ -export interface AirDataDto { - /** - * Device EUI - */ - dev_eui: string; - - /** - * Temperature in Celsius - */ - temperature_c?: number; - - /** - * Humidity percentage - */ - humidity?: number; - - /** - * CO level - */ - co?: number; - - /** - * CO2 level - */ - co2?: number; - - /** - * Pressure value - */ - pressure?: number; - - /** - * Light level in lux - */ - lux?: number; - - /** - * Battery level percentage - */ - battery_level?: number; - - /** - * UV index - */ - uv_index?: number; - - /** - * Rainfall amount - */ - rainfall?: number; - - /** - * Wind speed - */ - wind_speed?: number; - - /** - * Wind direction in degrees - */ - wind_direction?: number; - - /** - * Whether smoke is detected - */ - smoke_detected?: boolean; - - /** - * Whether vape is detected - */ - vape_detected?: boolean; - - /** - * Timestamp when the data was created - */ - created_at: string; - - /** - * Whether this data point is simulated - */ - is_simulated: boolean; -} - -/** - * DTO for historical/time-series air data - */ -export interface TimeSeriesAirDataDto { - /** - * Timestamp for the data point - */ - timestamp: string; - - /** - * Array of air data readings - */ - readings: AirDataDto[]; -} - -/** - * Maps an AirData entity to an AirDataDto - * @param airData The air data entity to map - * @returns AirDataDto - */ -export function toAirDataDto(airData: AirData): AirDataDto { - return { - dev_eui: airData.dev_eui, - temperature_c: airData.temperature_c ?? undefined, - humidity: airData.humidity ?? undefined, - co: airData.co ?? undefined, - co2: airData.co2 ?? undefined, - pressure: airData.pressure ?? undefined, - lux: airData.lux ?? undefined, - battery_level: airData.battery_level ?? undefined, - uv_index: airData.uv_index ?? undefined, - rainfall: airData.rainfall ?? undefined, - wind_speed: airData.wind_speed ?? undefined, - wind_direction: airData.wind_direction ?? undefined, - smoke_detected: airData.smoke_detected ?? undefined, - vape_detected: airData.vape_detected ?? undefined, - created_at: airData.created_at, - is_simulated: airData.is_simulated ?? false - }; -} - -/** - * Groups air data by time for time-series display - * @param airData Array of air data entities - * @param groupByInterval The interval to group by (e.g., 'day', 'hour') - * @returns Array of TimeSeriesAirDataDto - */ -export function toTimeSeriesAirDataDto( - airData: AirData[], - groupByInterval: 'hour' | 'day' | 'week' | 'month' = 'hour' -): TimeSeriesAirDataDto[] { - const groupedData = new Map(); - - // Group the data by the specified time interval - for (const data of airData) { - const date = new Date(data.created_at); - let groupKey: string; - - switch (groupByInterval) { - case 'day': - groupKey = date.toISOString().split('T')[0]; // YYYY-MM-DD - break; - case 'week': - // Get the date of the Monday of the current week - const day = date.getDay(); - const diff = date.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Sunday - const monday = new Date(date); - monday.setDate(diff); - groupKey = monday.toISOString().split('T')[0]; - break; - case 'month': - groupKey = date.toISOString().substring(0, 7); // YYYY-MM - break; - case 'hour': - default: - groupKey = date.toISOString().substring(0, 13); // YYYY-MM-DDTHH - } - - if (!groupedData.has(groupKey)) { - groupedData.set(groupKey, []); - } - - groupedData.get(groupKey)!.push(data); - } - - // Convert the grouped data to DTOs - return Array.from(groupedData.entries()).map(([timestamp, dataPoints]) => ({ - timestamp, - readings: dataPoints.map(toAirDataDto) - })); -} diff --git a/src/lib/dtos/DeviceDto.ts b/src/lib/dtos/DeviceDto.ts deleted file mode 100644 index 0d576261..00000000 --- a/src/lib/dtos/DeviceDto.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { Device, DeviceType, DeviceLocation } from '../models/Device'; - -/** - * DTO for device creation - */ -export interface CreateDeviceDto { - /** - * Device EUI (unique identifier) - */ - dev_eui: string; - - /** - * Device name - */ - name: string; - - /** - * Device type ID - */ - type?: number; - - /** - * Location ID where device is installed - */ - location_id?: number; - - /** - * Latitude coordinate - */ - lat?: number; - - /** - * Longitude coordinate - */ - long?: number; - - /** - * Serial number of the device - */ - serial_number?: string; - - /** - * Upload interval in seconds - */ - upload_interval?: number; -} - -/** - * DTO for device updates - */ -export interface UpdateDeviceDto { - /** - * Device name - */ - name?: string; - - /** - * Device type ID - */ - type?: number; - - /** - * Location ID where device is installed - */ - location_id?: number; - - /** - * Latitude coordinate - */ - lat?: number; - - /** - * Longitude coordinate - */ - long?: number; - - /** - * Upload interval in seconds - */ - upload_interval?: number; - - /** - * Date when battery was changed - */ - battery_changed_at?: string; -} - -/** - * DTO for device responses with minimal information - */ -export interface DeviceDto { - /** - * Device EUI (unique identifier) - */ - dev_eui: string; - - /** - * Device name - */ - name: string; - - /** - * Device type ID - */ - type?: number; - - /** - * Location ID where device is installed - */ - location_id?: number; - - /** - * Latitude coordinate - */ - lat?: number; - - /** - * Longitude coordinate - */ - long?: number; - - /** - * Upload interval in seconds - */ - upload_interval?: number; -} - -/** - * DTO for detailed device information including type and location - */ -export interface DeviceDetailDto extends DeviceDto { - /** - * Device type information - */ - deviceType?: DeviceTypeDto; - - /** - * Device location information - */ - location?: DeviceLocationDto; - - /** - * Serial number of the device - */ - serial_number?: string; - - /** - * Date when device was installed - */ - installed_at?: string; - - /** - * Date when battery was changed - */ - battery_changed_at?: string; - - /** - * Date when warranty started - */ - warranty_start_date?: string; -} - -/** - * DTO for device type information - */ -export interface DeviceTypeDto { - /** - * Device type ID - */ - id: number; - - /** - * Device type name - */ - name: string; - - /** - * Device manufacturer - */ - manufacturer?: string; - - /** - * Device model - */ - model?: string; -} - -/** - * DTO for device location information - */ -export interface DeviceLocationDto { - /** - * Location ID - */ - location_id: number; - - /** - * Location name - */ - name: string; - - /** - * Location description - */ - description?: string; - - /** - * Latitude coordinate - */ - lat?: number; - - /** - * Longitude coordinate - */ - long?: number; -} - -/** - * Maps a Device entity to a DeviceDto - * @param device The device entity to map - * @returns DeviceDto - */ -export function toDeviceDto(device: Device): DeviceDto { - return { - dev_eui: device.dev_eui, - name: device.name ?? '', - type: device.type ?? undefined, - location_id: device.location_id ?? undefined, - lat: device.lat ?? undefined, - long: device.long ?? undefined, - upload_interval: device.upload_interval ?? undefined - }; -} - -/** - * Maps a Device entity with type to a DeviceDetailDto - * @param device The device entity to map - * @param deviceType Optional device type information - * @param location Optional location information - * @returns DeviceDetailDto - */ -export function toDeviceDetailDto( - device: Device, - deviceType?: DeviceType, - location?: DeviceLocation -): DeviceDetailDto { - const detailDto: DeviceDetailDto = { - ...toDeviceDto(device), - serial_number: device.serial_number ?? undefined, - installed_at: device.installed_at ?? undefined, - battery_changed_at: device.battery_changed_at ?? undefined, - warranty_start_date: device.warranty_start_date ?? undefined - }; - - if (deviceType) { - detailDto.deviceType = { - id: deviceType.id, - name: deviceType.name ?? undefined, - manufacturer: deviceType.manufacturer ?? undefined, - model: deviceType.model ?? undefined - }; - } - - if (location) { - detailDto.location = { - location_id: location.location_id, - name: location.name, - description: location.description ?? undefined, - lat: location.lat ?? undefined, - long: location.long ?? undefined - }; - } - - return detailDto; -} diff --git a/src/lib/dtos/LocationDto.ts b/src/lib/dtos/LocationDto.ts deleted file mode 100644 index 26dc3301..00000000 --- a/src/lib/dtos/LocationDto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Location } from '../models/Location'; - -/** - * Data Transfer Object for location data - */ -export interface LocationDto { - /** - * Unique identifier for the location - */ - id: number; - - /** - * Name of the location - */ - name: string; - - /** - * Optional description of the location - */ - description?: string | null; - - /** - * Latitude coordinate - */ - latitude?: number | null; - - /** - * Longitude coordinate - */ - longitude?: number | null; - - /** - * Owner ID of the location - */ - ownerId?: string | null; - - /** - * Timestamp when the location was created - */ - createdAt: string; - - /** - * Map zoom level for display purposes - */ - mapZoom?: number | null; -} - -/** - * Convert a location entity to a DTO - * @param location The location entity to convert - */ -export function toLocationDto(location: Location): LocationDto { - return { - id: location.location_id, - name: location.name, - description: location.description, - latitude: location.lat, - longitude: location.long, - ownerId: location.owner_id, - createdAt: location.created_at, - mapZoom: location.map_zoom - }; -} - -/** - * Convert multiple location entities to DTOs - * @param locations Array of location entities - */ -export function toLocationDtos(locations: Location[]): LocationDto[] { - return locations.map(toLocationDto); -} \ No newline at end of file diff --git a/src/lib/dtos/RuleDto.ts b/src/lib/dtos/RuleDto.ts deleted file mode 100644 index b68a92a2..00000000 --- a/src/lib/dtos/RuleDto.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { Rule, RuleCriteria, RuleWithCriteria } from '../models/Rule'; - -/** - * Data Transfer Object for rule criteria - */ -export interface RuleCriteriaDto { - /** - * Unique identifier for the rule criteria - */ - id: number; - - /** - * The rule group this criteria belongs to - */ - ruleGroupId: string; - - /** - * The sensor type this criteria applies to - */ - sensorType: string; - - /** - * Comparison operator (e.g., '>', '<', '=') - */ - operator: string; - - /** - * Threshold value for comparison - */ - value: number; - - /** - * Optional unit of measurement - */ - unit?: string | null; - - /** - * Timestamp when the criteria was created - */ - createdAt: string; -} - -/** - * Data Transfer Object for rules - */ -export interface RuleDto { - /** - * Unique identifier for the rule - */ - id: number; - - /** - * The rule group ID for grouping multiple criteria - */ - ruleGroupId: string; - - /** - * Name of the rule - */ - name: string; - - /** - * Description of the rule - */ - description?: string | null; - - /** - * Device EUI the rule applies to - */ - deviceEui?: string | null; - - /** - * Profile ID the rule belongs to - */ - profileId?: string | null; - - /** - * Whether the rule is currently active - */ - isActive: boolean; - - /** - * Severity level of the rule (e.g., 'low', 'medium', 'high') - */ - severity?: string | null; - - /** - * Action to take when rule is triggered - */ - actionType?: string | null; - - /** - * Notification message to send when triggered - */ - message?: string | null; - - /** - * Timestamp when the rule was created - */ - createdAt: string; -} - -/** - * Data Transfer Object for rules with their criteria - */ -export interface RuleWithCriteriaDto extends RuleDto { - /** - * Array of criteria associated with this rule - */ - criteria: RuleCriteriaDto[]; -} - -/** - * Convert rule criteria entity to DTO - * @param criteria The rule criteria entity - */ -export function toRuleCriteriaDto(criteria: RuleCriteria): RuleCriteriaDto { - return { - id: criteria.id, - ruleGroupId: criteria.ruleGroupId, - sensorType: criteria.subject, - operator: criteria.operator, - value: criteria.trigger_value, - unit: null, - createdAt: criteria.created_at - }; -} - -/** - * Convert rule entity to DTO - * @param rule The rule entity - */ -export function toRuleDto(rule: Rule): RuleDto { - return { - id: rule.id, - ruleGroupId: rule.ruleGroupId, - name: rule.name, - description: null, - deviceEui: rule.dev_eui ?? null, - profileId: rule.profile_id ?? null, - isActive: !rule.is_triggered, - severity: null, - actionType: rule.notifier_type != null ? String(rule.notifier_type) : null, - message: null, - createdAt: rule.created_at - }; -} - -/** - * Convert rule with criteria entity to DTO - * @param ruleWithCriteria The rule with criteria entity - */ -export function toRuleWithCriteriaDto(ruleWithCriteria: RuleWithCriteria): RuleWithCriteriaDto { - return { - ...toRuleDto(ruleWithCriteria), - criteria: ruleWithCriteria.criteria.map(toRuleCriteriaDto) - }; -} - -/** - * Convert multiple rule entities to DTOs - * @param rules Array of rule entities - */ -export function toRuleDtos(rules: Rule[]): RuleDto[] { - return rules.map(toRuleDto); -} diff --git a/src/lib/dtos/SoilDataDto.ts b/src/lib/dtos/SoilDataDto.ts deleted file mode 100644 index b20905f3..00000000 --- a/src/lib/dtos/SoilDataDto.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { SoilData } from '../models/SoilData'; - -/** - * Data Transfer Object for soil data - */ -export interface SoilDataDto { - /** - * The device EUI that recorded this soil data - */ - devEui: string; - - /** - * Timestamp when the data was recorded - */ - timestamp: string; - - /** - * Soil temperature in Celsius - */ - temperature?: number | null; - - /** - * Soil moisture percentage - */ - moisture?: number | null; - - /** - * Soil electrical conductivity - */ - ec?: number | null; - - /** - * Soil pH level - */ - ph?: number | null; -} - -/** - * Convert a soil data entity to a DTO - * @param soilData The soil data entity to convert - */ -export function toSoilDataDto(soilData: SoilData): SoilDataDto { - return { - devEui: soilData.dev_eui, - timestamp: soilData.created_at, - temperature: soilData.temperature_c, - moisture: soilData.moisture, - ec: soilData.ec, - ph: soilData.ph - }; -} - -/** - * Convert multiple soil data entities to DTOs - * @param soilDataArray Array of soil data entities - */ -export function toSoilDataDtos(soilDataArray: SoilData[]): SoilDataDto[] { - return soilDataArray.map(toSoilDataDto); -} \ No newline at end of file diff --git a/src/lib/errors/AppError.ts b/src/lib/errors/AppError.ts deleted file mode 100644 index 9a8b1434..00000000 --- a/src/lib/errors/AppError.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Base application error class that extends the native Error class - * Provides additional context for application errors - */ -export class AppError extends Error { - /** - * HTTP status code for the error - */ - public statusCode: number; - - /** - * Error code for client-side error handling - */ - public errorCode: string; - - /** - * Whether the error is operational (expected) or programming (unexpected) - */ - public isOperational: boolean; - - /** - * Original error if this error wraps another - */ - public originalError?: Error; - - /** - * Constructor for AppError - * @param message Human-readable error message - * @param statusCode HTTP status code (defaults to 500) - * @param errorCode Error code for client-side error handling - * @param isOperational Whether the error is operational (expected) or programming (unexpected) - * @param originalError Original error if this error wraps another - */ - constructor( - message: string, - statusCode = 500, - errorCode = 'INTERNAL_ERROR', - isOperational = true, - originalError?: Error - ) { - super(message); - this.name = this.constructor.name; - this.statusCode = statusCode; - this.errorCode = errorCode; - this.isOperational = isOperational; - this.originalError = originalError; - - // Captures the stack trace, excluding the constructor call from it - Error.captureStackTrace(this, this.constructor); - } -} \ No newline at end of file diff --git a/src/lib/errors/ErrorHandlingService.ts b/src/lib/errors/ErrorHandlingService.ts deleted file mode 100644 index a7342f2d..00000000 --- a/src/lib/errors/ErrorHandlingService.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { AppError } from './AppError'; -import { DatabaseError, NotFoundError } from './SpecificErrors'; -import type { PostgrestError } from '@supabase/supabase-js'; - -/** - * Service for handling errors in a centralized way - */ -export class ErrorHandlingService { - /** - * Handle Supabase database errors - * Transforms Supabase errors into domain-specific errors - * @param error The Supabase error to handle - * @param customMessage Optional custom error message - */ - handleDatabaseError(error: PostgrestError | null, customMessage?: string): never { - if (!error) { - throw new DatabaseError('Unknown database error occurred'); - } - - // Handle specific PostgreSQL error codes - switch (error.code) { - case '23505': // unique_violation - throw new DatabaseError(customMessage || 'Duplicate entry found', error); - case '23503': // foreign_key_violation - throw new DatabaseError(customMessage || 'Referenced record not found', error); - case '42P01': // undefined_table - throw new DatabaseError(customMessage || 'Table not found', error); - case '42703': // undefined_column - throw new DatabaseError(customMessage || 'Column not found', error); - default: - throw new DatabaseError( - customMessage || `Database error: ${error.message || 'Unknown error'}`, - error - ); - } - } - - /** - * Handle case when no record is found - * @param entityName Name of the entity that wasn't found (e.g., 'Device') - * @param identifier Identifier that was used to look up the entity - */ - handleNotFound(entityName: string, identifier: string | number): never { - throw new NotFoundError(`${entityName} not found with identifier: ${identifier}`); - } - - /** - * Log errors to the appropriate logging service - * Can be expanded to send errors to external monitoring services - * @param error The error to log - */ - logError(error: Error | AppError): void { - const isAppError = error instanceof AppError; - - // Log different levels based on error type - if (isAppError && error.statusCode >= 500) { - console.error('[SERVER ERROR]', { - message: error.message, - errorCode: (error as AppError).errorCode, - stack: error.stack, - isOperational: (error as AppError).isOperational - }); - } else if (isAppError && error.statusCode >= 400) { - console.warn('[CLIENT ERROR]', { - message: error.message, - errorCode: (error as AppError).errorCode, - isOperational: (error as AppError).isOperational - }); - } else { - console.error('[UNEXPECTED ERROR]', { - message: error.message, - stack: error.stack - }); - } - - // Here you could add additional logging to external services - // like Sentry, LogRocket, etc. - } -} diff --git a/src/lib/errors/SpecificErrors.ts b/src/lib/errors/SpecificErrors.ts deleted file mode 100644 index b47268fe..00000000 --- a/src/lib/errors/SpecificErrors.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { AppError } from './AppError'; - -/** - * Error thrown when a requested resource is not found - */ -export class NotFoundError extends AppError { - constructor(message = 'Resource not found', originalError?: Error) { - super(message, 404, 'NOT_FOUND', true, originalError); - } -} - -/** - * Error thrown when validation fails - */ -export class ValidationError extends AppError { - /** - * Map of validation errors with field names as keys - */ - public validationErrors: Record; - - constructor(message = 'Validation error', validationErrors: Record = {}, originalError?: Error) { - super(message, 400, 'VALIDATION_ERROR', true, originalError); - this.validationErrors = validationErrors; - } -} - -/** - * Error thrown when a database operation fails - */ -export class DatabaseError extends AppError { - constructor(message = 'Database error occurred', originalError?: Error) { - super(message, 500, 'DATABASE_ERROR', true, originalError); - } -} - -/** - * Error thrown when a user is not authenticated - */ -export class UnauthorizedError extends AppError { - constructor(message = 'Authentication required', originalError?: Error) { - super(message, 401, 'UNAUTHORIZED', true, originalError); - } -} - -/** - * Error thrown when a user is forbidden from accessing a resource - */ -export class ForbiddenError extends AppError { - constructor(message = 'Forbidden', originalError?: Error) { - super(message, 403, 'FORBIDDEN', true, originalError); - } -} - -/** - * Error thrown when a service is unavailable or times out - */ -export class ServiceUnavailableError extends AppError { - constructor(message = 'Service unavailable', originalError?: Error) { - super(message, 503, 'SERVICE_UNAVAILABLE', true, originalError); - } -} - -/** - * Error thrown when there are conflicts like duplicate entries - */ -export class ConflictError extends AppError { - constructor(message = 'Resource conflict', originalError?: Error) { - super(message, 409, 'CONFLICT', true, originalError); - } -} \ No newline at end of file diff --git a/src/lib/html2canvas.d.ts b/src/lib/html2canvas.d.ts deleted file mode 100644 index 0f5bef07..00000000 --- a/src/lib/html2canvas.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Type definitions for html2canvas -interface Html2CanvasOptions { - scale?: number; - useCORS?: boolean; - logging?: boolean; - letterRendering?: boolean; - scrollX?: number; - scrollY?: number; - [key: string]: any; -} - -declare global { - interface Window { - html2canvas: (element: HTMLElement, options?: Html2CanvasOptions) => Promise; - } -} - -export {}; diff --git a/src/lib/i18n/format.ts b/src/lib/i18n/format.ts new file mode 100644 index 00000000..b99bd306 --- /dev/null +++ b/src/lib/i18n/format.ts @@ -0,0 +1,72 @@ +import { getLocale } from '$lib/paraglide/runtime'; + +type UiLocale = 'en' | 'ja'; +type DateLike = Date | string | number; + +function toDate(value: DateLike): Date | null { + const parsed = value instanceof Date ? value : new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function getUiLocale(locale = getLocale()): UiLocale { + return locale.startsWith('en') ? 'en' : 'ja'; +} + +export function getIntlLocale(locale = getLocale()): string { + return getUiLocale(locale) === 'en' ? 'en-US' : 'ja-JP'; +} + +export function formatDateTime( + value: DateLike, + options?: Intl.DateTimeFormatOptions, + fallback = '' +): string { + const parsed = toDate(value); + if (!parsed) return fallback; + + return new Intl.DateTimeFormat( + getIntlLocale(), + options ?? { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + } + ).format(parsed); +} + +export function formatDate( + value: DateLike, + options?: Intl.DateTimeFormatOptions, + fallback = '' +): string { + const parsed = toDate(value); + if (!parsed) return fallback; + + return new Intl.DateTimeFormat( + getIntlLocale(), + options ?? { + year: 'numeric', + month: 'short', + day: 'numeric' + } + ).format(parsed); +} + +export function formatNumber(value: number, options?: Intl.NumberFormatOptions): string { + return new Intl.NumberFormat(getIntlLocale(), options).format(value); +} + +export function formatCurrency(value: number, currency: string): string { + try { + return new Intl.NumberFormat(getIntlLocale(), { + style: 'currency', + currency: currency.toUpperCase(), + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(value); + } catch { + return `${value.toFixed(2)} ${currency.toUpperCase()}`; + } +} diff --git a/src/lib/i18n/index.svelte.ts b/src/lib/i18n/index.svelte.ts deleted file mode 100644 index eb945d31..00000000 --- a/src/lib/i18n/index.svelte.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { addMessages, init } from 'svelte-i18n'; - -let initialized = $state(false); - -export const i18n = { - get initialized(): boolean { - return initialized; - }, - - /** - * Initializes the Svelte i18n library with messages from locale files. - * @see https://github.com/kaisermann/svelte-i18n/blob/main/docs/Getting%20Started.md - * @see https://vitejs.dev/guide/features.html#glob-import - */ - async initialize({ - fallbackLocale = 'en', - initialLocale = 'ja' - }: { fallbackLocale?: string; initialLocale?: string } = {}) { - const modules: Record }> = import.meta.glob( - './locales/*.ts', - { eager: true } - ); - - Object.entries(modules).forEach(([path, { strings }]) => { - const [, locale] = path.match(/([a-zA-Z-]+)\.ts/) as string[]; - - addMessages(locale, strings); - }); - - await init({ fallbackLocale, initialLocale }); - initialized = true; - } -}; diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts deleted file mode 100644 index d9e23a22..00000000 --- a/src/lib/i18n/locales/en.ts +++ /dev/null @@ -1,535 +0,0 @@ -export const strings = { - 'Sensor Data - Today': 'Sensor Data - Today', - soil_moisture: 'Volumetric Water Content', - moisture: 'Volumetric Water Content', - Moisture: 'Volumetric Water Content', - soil_humidity: 'Volumetric Water Content', - humidity: 'Humidity', - Humidity: 'Humidity', - dew_point: 'Dew Point', - dew_pointC: 'Dew Point', - dewPointC: 'Dew Point', - Temperature: 'Temperature', - temperature: 'Temperature', - temperatureC: 'Temperature', - soil_temperatureC: 'Temperature', - temperature_c: 'Temperature', - soil_temperature: 'Temperature', - 'Soil Temperature': 'Temperature', - 'Temperature and Moisture': 'Soil Temperature/Volumetric Water Content', - 'Temperature and Humidity': 'Temperature/Humidity', - 'temperature and humidity': 'Temperature/Humidity', - temperature_and_humidity: 'Temperature/Humidity', - 'Temperature & Humidity': 'Temperature & Humidity', - 'temperature & humidity': 'Temperature & Humidity', - soil_EC: 'Soil Electrical Conductivity', - soil_ec: 'Soil Electrical Conductivity', - soil_N: 'Soil Nutrients', - soil_P: 'Soil Nutrients', - soil_K: 'Soil Nutrients', - soil_n: 'Soil Nutrients', - soil_p: 'Soil Nutrients', - soil_k: 'Soil Nutrients', - soil_PH: 'Soil pH', - soil_ph: 'Soil pH', - co2_level: 'CO2 Concentration', - vpd: 'Vapor Pressure Deficit', - rainfall: 'Rainfall', - deapth_cm: 'Depth (mm)', - depth_cm: 'Depth (mm)', - pressure: 'Atmospheric Pressure', - created_at: 'Last Updated', - wind_speed: 'Wind Speed', - lux: 'Illuminance', - uv: 'Ultraviolet', - uv_index: 'UV Index', - wind_direction: 'Wind Direction', - water_level: 'Water Level', - battery_level: 'Battery Level', - battery: 'Battery Level', - Battery: 'Battery Level', - sos: 'Emergency Signal', - fire: 'Fire', - people_count: 'People', - car_count: 'Vehicles', - bicycle_count: 'Bicycles', - truck_count: 'Trucks', - bus_count: 'Buses', - motorcycle_count: 'Motorcycles', - 'Data Date Range': 'Data Date Range', - data_date_range: 'Data Date Range', - // All Reports page - all_reports_heading: 'All Reports', - all_reports_subheading: 'Manage and view all your configured reports', - search_reports_placeholder: 'Search reports...', - download_selected: 'Download Selected', - downloading: 'Downloading...', - bulk_download_description: 'Choose the date range used for each selected report download.', - selection: 'Selection', - select_all_reports_aria: 'Select all reports', - select_report_aria: 'Select report {name}', - bulk_progress_message: 'Downloading {current} of {total}...', - bulk_missing_date_range: 'Please choose a valid date range.', - bulk_invalid_date_selection: 'Date selection is invalid.', - bulk_start_after_end: 'Start date must be before end date.', - bulk_no_reports_available: 'No reports available to download.', - bulk_report_missing_identifier: 'Report {name} is missing a device identifier.', - bulk_unable_to_download: 'Unable to download {name}.', - bulk_partial_suffix: ' (some failed)', - bulk_download_success: '{count} {reportWord} downloaded{partial}.', - download: 'Download', - no_reports_match_search: 'No reports match your search.', - no_reports_found: 'No reports found.', - untitled_report: 'Untitled report', - None: 'None', - report_deleted: 'Report deleted', - failed_to_delete: 'Failed to delete', - - // CSVDownloadButton translations - select_date_range: 'Select Date Range', - select_start_end_dates: 'Select start and end dates', - start_date: 'Start Date', - end_date: 'End Date', - Close: 'Close', - close: 'Close', - Open: 'Open', - open: 'Open', - Search: 'Search', - search: 'Search', - 'Sort By': 'Sort By', - 'sort by': 'Sort By', - 'Load Selected Data': 'Load Selected Data', - load_selected_data: 'Load Selected Data', - 'Load Selected Range': 'Load Selected Range', - load_selected_range: 'Load Selected Range', - export: 'Export', - csv: 'CSV', - download_csv: 'CSV', - 'Download Excel': 'Download Excel', - download_excel: 'Download Excel', - 'Download PDF': 'Download PDF', - pdf: 'PDF', - download_pdf: 'Download PDF', - Report: 'Report', - report: 'Report', - Reports: 'Reports', - Settings: 'Settings', - settings: 'Settings', - Back: 'Back', - back: 'Back', - 'Back to Device': 'Back to Device', - 'Last Seen': 'Last Seen', - 'Last seen': 'Last Seen', - 'last seen': 'Last Seen', - Counts: 'Counts', - counts: 'Counts', - ago: 'ago', - Dashboard: 'Dashboard', - dashboard: 'Dashboard', - Login: 'Login', - login: 'Login', - Logout: 'Logout', - logout: 'Logout', - Register: 'Register', - register: 'Register', - 'Registering...': 'Registering...', - 'Create your account': 'Create your account', - Or: 'Or', - 'sign in to your existing account': 'sign in to your existing account', - 'First Name': 'First Name', - 'Last Name': 'Last Name', - John: 'John', - Doe: 'Doe', - 'Email address': 'Email address', - 'you@example.com': 'you@example.com', - Password: 'Password', - 'Confirm Password': 'Confirm Password', - 'Passwords must match': 'Passwords must match', - 'At least 8 characters': 'At least 8 characters', - 'Contains a lowercase letter (a-z)': 'Contains a lowercase letter (a-z)', - 'Contains an uppercase letter (A-Z)': 'Contains an uppercase letter (A-Z)', - 'Contains a number (0-9)': 'Contains a number (0-9)', - 'Contains a symbol (!@#$, etc.)': 'Contains a symbol (!@#$, etc.)', - Company: 'Company', - 'Acme Inc.': 'Acme Inc.', - 'I agree to the': 'I agree to the', - 'Terms of Service': 'Terms of Service', - 'Privacy Policy': 'Privacy Policy', - 'Cookie Policy': 'Cookie Policy', - 'Required Agreements': 'Required Agreements', - 'All three agreements must be accepted to register': - 'All three agreements must be accepted to register', - 'First name is required': 'First name is required', - 'Last name is required': 'Last name is required', - 'Email is required': 'Email is required', - 'Please enter a valid email address': 'Please enter a valid email address', - 'Password is required': 'Password is required', - 'Password must be at least 8 characters': 'Password must be at least 8 characters', - 'Password must be at least 8 characters and include a lowercase letter, uppercase letter, number, and symbol': - 'Password must be at least 8 characters and include a lowercase letter, uppercase letter, number, and symbol', - 'Passwords do not match': 'Passwords do not match', - 'Please confirm your password': 'Please confirm your password', - 'Company name is required': 'Company name is required', - 'You must agree to all terms and policies': 'You must agree to all terms and policies', - 'An unexpected error occurred. Please try again.': - 'An unexpected error occurred. Please try again.', - 'Registration failed. Please try again.': 'Registration failed. Please try again.', - - // Password Reset/Update - 'Forgot Password': 'Forgot Password', - 'Reset Password': 'Reset Password', - 'Update Password': 'Update Password', - 'New Password': 'New Password', - 'Confirm New Password': 'Confirm New Password', - 'Enter your new password': 'Enter your new password', - 'Confirm your new password': 'Confirm your new password', - 'Minimum 8 characters required': 'Minimum 8 characters required', - 'Please enter your new password below.': 'Please enter your new password below.', - 'Your password has been successfully updated!': 'Your password has been successfully updated!', - 'You can now log in with your new password.': 'You can now log in with your new password.', - 'Go to login': 'Go to login', - 'Updating Password...': 'Updating Password...', - 'Password updated successfully! You can now log in with your new password.': - 'Password updated successfully! You can now log in with your new password.', - 'Failed to update password': 'Failed to update password', - "Enter your email address and we'll send you a link to reset your password.": - "Enter your email address and we'll send you a link to reset your password.", - 'Send reset link': 'Send reset link', - 'Sending...': 'Sending...', - 'Password reset link sent to your email.': 'Password reset link sent to your email.', - 'Back to login': 'Back to login', - 'Still needed': 'Still needed', - 'If an account exists with the email': 'If an account exists with the email', - "you'll receive a password reset link shortly.": "you'll receive a password reset link shortly.", - - // Header - 'IoT Dashboard': 'IoT Dashboard', - Welcome: 'Welcome', - Pricing: 'Pricing', - Devices: 'Devices', - Resources: 'Resources', - About: 'About', - Demo: 'Demo', - 'Get Started': 'Get Started', - - // Stats labels - Min: 'Min', - Avg: 'Avg', - Max: 'Max', - Count: 'Count', - Median: 'Median', - 'Std Dev': 'Std Dev', - Range: 'Range', - 'Click to expand': 'Click to expand', - 'Click to collapse': 'Click to collapse', - - // Login page - 'Go to Dashboard': 'Go to Dashboard', - 'Go to API': 'Go to API', - 'Create an account': 'Create an account', - 'Login with Discord': 'Login with Discord', - 'N/A': 'N/A', - 'Enter your email': 'Enter your email', - 'Enter your password': 'Enter your password', - 'Logging in...': 'Logging in...', - 'You are already logged in': 'You are already logged in', - 'Login successful! Redirecting to dashboard...': 'Login successful! Redirecting to dashboard...', - Locations: 'Locations', - Details: 'Details', - 'View Details': 'View Details', - 'No locations found.': 'No locations found.', - 'Latest Sensor Readings': 'Latest Sensor Readings', - 'No recent data available': 'No recent data available', - 'Camera Stream': 'Camera Stream', - 'No stream available': 'No stream available', - Map: 'Map', - 'Last updated:': 'Last updated:', - 'Stats Summary': 'Stats Summary', - 'Data Chart': 'Data Chart', - 'All Sensor Data Over Time': 'All Sensor Data Over Time', - 'Weather & Data': 'Weather & Data', - 'Loading historical data...': 'Loading historical data...', - 'No historical data available for the selected date range.': - 'No historical data available for the selected date range.', - 'Device Settings': 'Device Settings', - General: 'General', - 'Manage the device information.': 'Manage the device information.', - Update: 'Update', - 'Dangerous Zone': 'Dangerous Zone', - 'Delete Device & Associated Data': 'Delete Device & Associated Data', - Delete: 'Delete', - Cancel: 'Cancel', - delete_device_warning: - 'DELETING THIS DEVICE WILL BE PERMANENT AND IRREVERSIBLE. ALL DATA ASSOCIATED WITH THIS DEVICE WILL BE LOST. WE WILL NOT BE ABLE TO RECOVER THIS DATA. DO NOT DO THIS UNLESS YOU KNOW WHAT YOU ARE DOING!', - 'Loading...': 'Loading...', - 'Unknown Location': 'Unknown Location', - 'Settings updated successfully!': 'Settings updated successfully!', - 'Failed to update settings': 'Failed to update settings', - 'Failed to delete device': 'Failed to delete device', - 'Relay control': 'Relay control', - 'Two big buttons for each relay. Tap once and wait for the light to change.': - 'Two big buttons for each relay. Tap once and wait for the light to change.', - 'Relay 1': 'Relay 1', - 'Relay 2': 'Relay 2', - 'Next action available in {seconds}s': 'Next action available in {seconds}s', - 'Checking relay status…': 'Checking relay status…', - 'Checking status…': 'Checking status…', - 'Currently ON': 'Currently ON', - 'Currently OFF': 'Currently OFF', - ON: 'ON', - OFF: 'OFF', - 'Sending command…': 'Sending command…', - 'Relay {number} {state}': 'Relay {number} {state}', - 'Downlink failed: {reason}': 'Downlink failed: {reason}', - 'Downlink failed': 'Downlink failed', - Permissions: 'Permissions', - manage_permissions_description: 'Manage who has access and their permission levels.', - Notifications: 'Notifications', - current_users: 'Current Users', - no_additional_users: 'No additional users.', - owner: 'Owner', - updating: 'Updating...', - remove_user: 'Remove User', - confirm_remove_user: 'Are you sure you want to remove this user?', - cancel: 'Cancel', - remove: 'Remove', - user_removed_success: 'User removed successfully', - user_remove_error: 'An error occurred while removing the user', - permission_update_success: 'Permission updated successfully', - permission_update_error: 'Failed to update permission', - 'Location Settings': 'Location Settings', - 'Create Location': 'Create Location', - 'Add a new location to your account.': 'Add a new location to your account.', - 'Location Name': 'Location Name', - 'Location Description': 'Location Description', - 'Describe the location (optional)': 'Describe the location (optional)', - 'Pick a point on the map or adjust the values manually.': - 'Pick a point on the map or adjust the values manually.', - 'Use Current Location': 'Use Current Location', - 'Locating...': 'Locating...', - 'We use your browser location to prefill coordinates.': - 'We use your browser location to prefill coordinates.', - 'Browser location is unavailable; enter coordinates manually.': - 'Browser location is unavailable; enter coordinates manually.', - 'Location Preview': 'Location Preview', - 'Click the map to fine-tune the coordinates.': 'Click the map to fine-tune the coordinates.', - 'Location Details': 'Location Details', - 'Add Device': 'Add Device', - 'Location ID': 'Location ID', - Created: 'Created', - Coordinates: 'Coordinates', - 'Not set': 'Not set', - 'Update Location': 'Update Location', - 'Updating...': 'Updating...', - 'Add New User': 'Add New User', - 'Email Address': 'Email Address', - 'Permission Level': 'Permission Level', - 'Admin:': 'Admin:', - 'User:': 'User:', - 'Viewer:': 'Viewer:', - 'Disabled:': 'Disabled:', - 'Apply same permission to all devices in this location': - 'Apply same permission to all devices in this location', - 'If unchecked, user will be added with "Disabled" permission to all devices.': - 'If unchecked, user will be added with "Disabled" permission to all devices.', - 'Adding...': 'Adding...', - 'Add User': 'Add User', - 'Remove Location': 'Remove Location', - 'Are you sure you want to remove this location? This action cannot be undone.': - 'Are you sure you want to remove this location? This action cannot be undone.', - 'Location details updated successfully': 'Location details updated successfully', - 'Failed to update location details': 'Failed to update location details', - 'User added successfully': 'User added successfully', - 'Failed to add user': 'Failed to add user', - 'Location removed successfully': 'Location removed successfully', - 'Failed to remove location': 'Failed to remove location', - 'An error occurred': 'An error occurred', - 'An error occurred while removing the location': 'An error occurred while removing the location', - 'No name set': 'No name set', - 'Full control over location and devices': 'Full control over location and devices', - 'Can use and configure devices': 'Can use and configure devices', - 'Can only view data': 'Can only view data', - 'No access': 'No access', - 'No devices found for this location.': 'No devices found for this location.', - '« Back to Location Overview': '« Back to Location Overview', - 'Create a New Device': 'Create a New Device', - 'Create a new device to be used in the location {location}.': - 'Create a new device to be used in the location {location}.', - 'Device Name': 'Device Name', - 'Dev EUI': 'Dev EUI', - Latitude: 'Latitude', - Longitude: 'Longitude', - 'Device Type': 'Device Type', - 'Select a device type...': 'Select a device type...', - 'User Permissions': 'User Permissions', - 'Set All {label}': 'Set All {label}', - 'Set All Admin': 'Set All Admin', - 'Set All User': 'Set All User', - 'Set All Viewer': 'Set All Viewer', - 'Set All Disabled': 'Set All Disabled', - 'Creating...': 'Creating...', - 'Create Device': 'Create Device', - 'Get notified when the device meets a specific condition.': - 'Get notified when the device meets a specific condition.', - 'Add Rule': 'Add Rule', - 'No rules found.': 'No rules found.', - Name: 'Name', - edit: 'Edit', - Actions: 'Actions', - 'No recipients': 'No recipients', - 'No criteria defined': 'No criteria defined', - 'Delete Rule': 'Delete Rule', - 'Are you sure you want to delete this rule? This action cannot be undone.': - 'Are you sure you want to delete this rule? This action cannot be undone.', - 'Deleting...': 'Deleting...', - 'Edit Rule': 'Edit Rule', - 'Create New Rule': 'Create New Rule', - 'Rule deleted successfully': 'Rule deleted successfully', - 'Failed to delete rule': 'Failed to delete rule', - 'Create Rule': 'Create Rule', - 'Update Rule': 'Update Rule', - 'Rule created successfully': 'Rule created successfully', - 'Rule updated successfully': 'Rule updated successfully', - 'Failed to create rule': 'Failed to create rule', - 'Failed to update rule': 'Failed to update rule', - Add: 'Add', - Conditions: 'Conditions', - 'Define one or more conditions that will trigger this rule.': - 'Define one or more conditions that will trigger this rule.', - 'Remove condition': 'Remove condition', - Subject: 'Subject', - Operation: 'Operation', - 'Trigger Value': 'Trigger Value', - 'Reset Value': 'Reset Value', - 'Add Another Condition': 'Add Another Condition', - 'Enter email address': 'Enter email address', - Email: 'Email', - SMS: 'SMS', - 'Push Notification': 'Push Notification', - Method: 'Method', - Recipients: 'Recipients', - - // Report page - add_report: 'Add Report', - generate_report: 'Generate Report', - generate_sample_report: 'Generate Sample Report', - exporting_data: 'Exporting {type} data...', - - // PDF report content - device_report: 'Device Report', - device_report_weekly: 'Weekly', - device_report_monthly: 'Monthly', - report_date_range: 'Report Date Range', - generated_at: 'Created At', - generated_by: 'Created By', - installed_at: 'Installed at', - date: 'Date', - created: 'Created', - verified: 'Verified', - approved: 'Approved', - sampling_size: 'Sampling Size', - date_range: 'Date Range', - status: 'Status', - normal: 'Normal', - notice: 'Notice', - warning: 'Warning', - alert: 'Alert', - min: 'Min', - max: 'Max', - avg: 'Avg', - stddev: 'Standard Deviation', - datetime: 'Date/Time', - comment: 'Comment', - sensor_data_trends: 'Sensor Data Trends', - legend: 'Legend', - time: 'Time', - value: 'Value', - summary: 'Summary', - data_history: 'Data History', - - // Settings page - Unknown: 'Unknown', - EUI: 'EUI', - 'Installed Date': 'Installed Date', - 'Device Location': 'Device Location', - 'Units & Display Settings': 'Units & Display Settings', - - // Error pages - 'Device Error': 'Device Error', - 'There was an error loading the device. Please try again or contact our support team for assistance.': - 'There was an error loading the device. Please try again or contact our support team for assistance.', - 'Back to Devices': 'Back to Devices', - 'Contact Support': 'Contact Support', - 'Go to Device Settings': 'Go to Device Settings', - 'Start Date:': 'Start Date:', - 'End Date:': 'End Date:', - 'Expand sidebar to search': 'Expand sidebar to search', - 'Collapse sidebar': 'Collapse sidebar', - - // Account Settings - 'General Account Settings': 'General Account Settings', - 'Settings that affect your entire account, including your profile and preferences.': - 'Settings that affect your entire account, including your profile and preferences.', - 'Profile Information': 'Profile Information', - 'Profile Picture': 'Profile Picture', - 'Click to upload a new profile picture': 'Click to upload a new profile picture', - 'Your email address cannot be changed': 'Your email address cannot be changed', - 'Enter your full name': 'Enter your full name', - 'Enter your username': 'Enter your username', - 'https://example.com': 'https://example.com', - 'Account Actions': 'Account Actions', - 'Need help with your account?': 'Need help with your account?', - 'Contact support': 'Contact support', - - // Display Settings - 'Choose your preferred units of measurement and display options for your account.': - 'Choose your preferred units of measurement and display options for your account.', - 'Unit Preferences': 'Unit Preferences', - 'Display Preferences': 'Display Preferences', - Timezone: 'Timezone', - UTC: 'UTC', - 'Eastern Time (EST)': 'Eastern Time (EST)', - 'Central Time (CST)': 'Central Time (CST)', - 'Mountain Time (MT)': 'Mountain Time (MT)', - 'Pacific Time (PST)': 'Pacific Time (PST)', - 'Greenwich Mean Time (GMT)': 'Greenwich Mean Time (GMT)', - 'Central European Summer Time (CEST)': 'Central European Summer Time (CEST)', - 'Central European Time (CET)': 'Central European Time (CET)', - 'Indian Standard Time (IST)': 'Indian Standard Time (IST)', - 'Japan Standard Time (JST)': 'Japan Standard Time (JST)', - 'Australian Eastern Standard Time (AEST)': 'Australian Eastern Standard Time (AEST)', - 'New Zealand Standard Time (NZST)': 'New Zealand Standard Time (NZST)', - 'Temperature Unit': 'Temperature Unit', - Celsius: 'Celsius', - Fahrenheit: 'Fahrenheit', - Kelvin: 'Kelvin', - 'Distance Unit': 'Distance Unit', - Meters: 'Meters', - Millimeters: 'Millimeters', - Centimetre: 'Centimetre', - Feet: 'Feet', - Inches: 'Inches', - Yards: 'Yards', - 'EC Unit': 'EC Unit', - 'Date Format': 'Date Format', - 'DD-MM-YYYY': 'DD-MM-YYYY', - 'MM-DD-YYYY': 'MM-DD-YYYY', - 'YYYY-MM-DD': 'YYYY-MM-DD', - 'DD/MM/YYYY': 'DD/MM/YYYY', - 'MM/DD/YYYY': 'MM/DD/YYYY', - 'YYYY/MM/DD': 'YYYY/MM/DD', - 'DD.MM.YYYY': 'DD.MM.YYYY', - 'MM.DD.YYYY': 'MM.DD.YYYY', - 'YYYY.MM.DD': 'YYYY.MM.DD', - 'ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)': 'ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)', - 'Time Format': 'Time Format', - '12-Hour': '12-Hour', - '24-Hour': '24-Hour', - 'ISO 8601 (HH:mm:ss)': 'ISO 8601 (HH:mm:ss)', - 'Number Format': 'Number Format', - 'No Decimal Places': 'No Decimal Places', - 'One Decimal Place': 'One Decimal Place', - 'Two Decimal Places': 'Two Decimal Places', - 'Three Decimal Places': 'Three Decimal Places', - 'Save Settings': 'Save Settings' -}; diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts deleted file mode 100644 index f05629ec..00000000 --- a/src/lib/i18n/locales/ja.ts +++ /dev/null @@ -1,595 +0,0 @@ -export const strings = { - 'Sensor Data - Today': 'センサーデータ - 本日', - soil_moisture: '土壌水分量', - moisture: '土壌水分量', - Moisture: '土壌水分量', - soil_humidity: '土壌水分量', - humidity: '湿度', - Humidity: '湿度', - dew_point: '露点', - dew_pointC: '露点', - dewPointC: '露点', - Temperature: '温度', - temperature: '温度', - temperatureC: '温度', - soil_temperatureC: '温度', - temperature_c: '温度', - soil_temperature: '温度', - 'Soil Temperature': '温度', - 'Temperature and Moisture': '土壌温度/土壌水分量', - 'Temperature and Humidity': '温度/湿度', - 'temperature and humidity': '温度/湿度', - temperature_and_humidity: '温度/湿度', - 'Temperature & Humidity': '温度/湿度', - 'temperature & humidity': '温度/湿度', - soil_EC: '土壌電気伝導率', - soil_ec: '土壌電気伝導率', - soil_N: '土壌養分', - soil_P: '土壌養分', - soil_K: '土壌養分', - soil_n: '土壌養分', - soil_p: '土壌養分', - soil_k: '土壌養分', - soil_PH: '土壌pH', - soil_ph: '土壌pH', - co2_level: 'CO2濃度', - vpd: '飽和水蒸気圧差', - rainfall: '降雨量', - deapth_cm: '深さ(mm)', - depth_cm: '深さ(mm)', - pressure: '気圧', - created_at: '最終更新', - Created: '作成日', - wind_speed: '風速', - lux: '照度', - uv: '紫外線', - uv_index: '紫外線指数', - wind_direction: '風向', - water_level: '水位', - battery_level: '電池残量', - battery: '電池残量', - Battery: '電池残量', - sos: '緊急信号', - fire: '火災', - people_count: '人数', - car_count: '車両台数', - bicycle_count: '自転車台数', - truck_count: 'トラック台数', - bus_count: 'バス台数', - motorcycle_count: 'バイク台数', - 'Data Date Range': 'データ日付範囲', - data_date_range: 'データ日付範囲', - // All Reports page - all_reports_heading: 'レポート一覧', - all_reports_subheading: '設定済みのレポートを管理・表示します', - search_reports_placeholder: 'レポートを検索…', - download_selected: '選択したレポートをダウンロード', - downloading: 'ダウンロード中...', - bulk_download_description: '選択した各レポートのダウンロードに使用する期間を選択してください。', - selection: '選択', - select_all_reports_aria: 'すべてのレポートを選択', - select_report_aria: 'レポート「{name}」を選択', - bulk_progress_message: '{total} 件中 {current} 件をダウンロード中...', - bulk_missing_date_range: '有効な期間を選択してください。', - bulk_invalid_date_selection: '日付の選択が正しくありません。', - bulk_start_after_end: '開始日は終了日より前である必要があります。', - bulk_no_reports_available: 'ダウンロード可能なレポートがありません。', - bulk_report_missing_identifier: 'レポート「{name}」にデバイスIDがありません。', - bulk_unable_to_download: '「{name}」をダウンロードできませんでした。', - bulk_partial_suffix: '(一部失敗)', - bulk_download_success: '{reportWord}{count}件をダウンロードしました{partial}。', - download: 'ダウンロード', - no_reports_match_search: '検索条件に一致するレポートがありません。', - no_reports_found: 'レポートが見つかりません。', - untitled_report: '名称未設定のレポート', - None: 'なし', - report_deleted: 'レポートを削除しました', - failed_to_delete: '削除に失敗しました', - Close: '閉じる', - close: '閉じる', - Open: '開く', - open: '開く', - Search: '検索', - search: '検索', - 'Sort By': '並び替え', - 'sort by': '並び替え', - 'Load Selected Data': '選択したデータを読み込む', - load_selected_data: '選択したデータを読み込む', - 'Load Selected Range': '選択したデータを読み込む', - load_selected_range: '選択したデータを読み込む', - export: 'エクスポート', - csv: 'CSV', - download_csv: 'CSV', - 'Download Excel': 'Excelをダウンロード', - download_excel: 'Excelをダウンロード', - 'Download PDF': 'PDFをダウンロード', - pdf: 'PDF', - download_pdf: 'PDFをダウンロード', - Report: 'レポート', - report: 'レポート', - Reports: 'レポート', - Settings: '設定', - settings: '設定', - Back: '戻る', - back: '戻る', - 'Back to Device': 'デバイスに戻る', - 'Last Seen': '最終接続', - 'Last seen': '最終接続', - 'last seen': '最終接続', - Counts: 'カウント', - counts: 'カウント', - ago: '前', - Dashboard: 'ダッシュボード', - dashboard: 'ダッシュボード', - Login: 'ログイン', - login: 'ログイン', - Logout: 'ログアウト', - logout: 'ログアウト', - Register: '登録', - 'Registering...': '登録中...', - 'Create your account': 'アカウントを作成', - Or: 'または', - 'sign in to your existing account': '既存のアカウントでサインイン', - 'First Name': '名', - 'Last Name': '姓', - John: '太郎', - Doe: '山田', - 'Email address': 'メールアドレス', - 'you@example.com': 'you@example.com', - Password: 'パスワード', - 'At least 8 characters': '8文字以上', - 'Contains a lowercase letter (a-z)': '英小文字 (a-z) を含む', - 'Contains an uppercase letter (A-Z)': '英大文字 (A-Z) を含む', - 'Contains a number (0-9)': '数字 (0-9) を含む', - 'Contains a symbol (!@#$, etc.)': '記号 (!@#$ など) を含む', - 'Passwords must match': 'パスワードが一致している必要があります', - Company: '会社名', - 'Acme Inc.': '株式会社サンプル', - 'I agree to the': '同意します', - 'Terms of Service': '利用規約', - 'Privacy Policy': 'プライバシーポリシー', - 'Cookie Policy': 'クッキーポリシー', - 'Required Agreements': '必須の同意事項', - 'All three agreements must be accepted to register': '登録するには3つすべての同意が必要です', - 'First name is required': '名は必須です', - 'Last name is required': '姓は必須です', - 'Email is required': 'メールアドレスは必須です', - 'Please enter a valid email address': '有効なメールアドレスを入力してください', - 'Password is required': 'パスワードは必須です', - 'Password must be at least 8 characters': 'パスワードは8文字以上で入力してください', - 'Password must be at least 8 characters and include a lowercase letter, uppercase letter, number, and symbol': - 'パスワードは8文字以上で、英小文字・英大文字・数字・記号を含める必要があります', - 'Please confirm your password': 'パスワードを確認してください', - 'Passwords do not match': 'パスワードが一致しません', - 'Company name is required': '会社名は必須です', - 'You must agree to all terms and policies': 'すべての規約とポリシーに同意してください', - 'An unexpected error occurred. Please try again.': - '予期しないエラーが発生しました。再度お試しください。', - 'Registration failed. Please try again.': '登録に失敗しました。再度お試しください。', - 'Forgot Password': 'パスワードを忘れた', - forgot_password: 'パスワードを忘れた', - 'Reset Password': 'パスワードをリセット', - reset_password: 'パスワードをリセット', - 'Update Password': 'パスワードを更新', - 'New Password': '新しいパスワード', - 'Confirm New Password': '新しいパスワードを確認', - 'Enter your new password': '新しいパスワードを入力してください', - 'Confirm your new password': '新しいパスワードを確認してください', - 'Minimum 8 characters required': '最低8文字が必要です', - 'Please enter your new password below.': '下に新しいパスワードを入力してください。', - 'Your password has been successfully updated!': 'パスワードが正常に更新されました!', - 'You can now log in with your new password.': '新しいパスワードでログインできます。', - 'Go to login': 'ログインに移動', - 'Updating Password...': 'パスワード更新中...', - 'Password updated successfully! You can now log in with your new password.': - 'パスワードが正常に更新されました!新しいパスワードでログインできます。', - 'Failed to update password': 'パスワードの更新に失敗しました', - "Enter your email address and we'll send you a link to reset your password.": - 'メールアドレスを入力していただければ、パスワードリセット用のリンクをお送りします。', - 'Send reset link': 'リセットリンクを送信', - 'Sending...': '送信中...', - 'Password reset link sent to your email.': 'パスワードリセットリンクがメールに送信されました。', - 'Back to login': 'ログインに戻る', - 'Still needed': '残りの条件', - 'If an account exists with the email': 'メールでアカウントが存在する場合', - "you'll receive a password reset link shortly.": - 'まもなくパスワードリセットリンクを受け取ります。', - Email: 'メール', - email: 'メール', - password: 'パスワード', - 'Confirm Password': 'パスワードを確認', - confirm_password: 'パスワードを確認', - Name: '名前', - name: '名前', - 'Remember Me': '私を覚えてますか', - remember_me: '私を覚えてますか', - 'All Locations': 'すべての場所', - all_locations: 'すべての場所', - Help: 'ヘルプ', - help: 'ヘルプ', - Refresh: 'リフレッシュ', - refresh: 'リフレッシュ', - 'Dashboard Style': 'ダッシュボードスタイル', - 'Hide/Show Empty': '空を隠す/表示する', - '1d': '1日', - '2d': '2日', - '3d': '3日', - '1w': '1週間', - '2w': '2週間', - '3w': '3週間', - '1m': '1ヶ月', - '2m': '2ヶ月', - 'Device Permissions': 'デバイスの権限', - Administrator: '管理者', - User: 'ユーザー', - Guest: 'ゲスト', - View: '表示', - Disabled: '無効', - 'Device Rules': 'デバイスのルール', - Rule: 'ルール', - 'Rule Name': 'ルール名', - 'CO²': 'CO²', - co2: 'CO²', - EC: 'EC', - ec: 'EC', - ph: 'pH', - PH: 'pH', - 'All Devices': 'すべてのデバイス', - - // Header - 'IoT Dashboard': 'IoTダッシュボード', - Welcome: 'ようこそ', - Pricing: '価格', - Devices: 'デバイス', - Resources: 'リソース', - About: '概要', - Demo: 'デモ', - 'Get Started': '始める', - - // Stats labels - Min: '最小', - Avg: '平均', - Max: '最大', - Count: '数', - Median: '中央値', - 'Std Dev': '標準偏差', - Range: '範囲', - 'Click to expand': 'クリックして展開', - 'Click to collapse': 'クリックして折りたたむ', - - // Login page - 'Go to Dashboard': 'ダッシュボードへ', - 'Go to API': 'APIへ', - 'Create an account': 'アカウントを作成', - 'Login with Discord': 'Discordでログイン', - 'N/A': 'N/A', - 'Enter your email': 'メールアドレスを入力', - 'Enter your password': 'パスワードを入力', - 'Logging in...': 'ログイン中...', - 'You are already logged in': 'すでにログインしています', - 'Login successful! Redirecting to dashboard...': - 'ログイン成功!ダッシュボードにリダイレクトしています...', - Locations: '場所', - Details: '詳細', - 'View Details': '詳細を見る', - 'No locations found.': '場所が見つかりません。', - 'Latest Sensor Readings': '最新のセンサー読取値', - 'No recent data available': '最近のデータはありません', - 'Camera Stream': 'カメラストリーム', - 'No stream available': 'ストリームは利用できません', - Map: '地図', - 'Last updated:': '最終更新:', - 'Stats Summary': '統計概要', - 'Data Chart': 'データチャート', - 'All Sensor Data Over Time': 'センサー全データの推移', - 'Weather & Data': '天気とデータ', - 'Loading historical data...': '過去のデータを読み込み中...', - 'No historical data available for the selected date range.': '選択した期間のデータはありません。', - 'Device Settings': 'デバイス設定', - General: '一般', - 'Manage the device information.': 'デバイス情報を管理します。', - Update: '更新', - 'Dangerous Zone': '危険ゾーン', - 'Delete Device & Associated Data': 'デバイスと関連データを削除', - Delete: '削除', - Cancel: 'キャンセル', - delete_device_warning: - 'このデバイスを削除すると、すべての関連データが完全かつ永久に失われます。データの復元はできません。本当に必要な場合のみ実行してください。', - 'Loading...': '読み込み中...', - 'Unknown Location': '不明な場所', - 'Settings updated successfully!': '設定が正常に更新されました!', - 'Failed to update settings': '設定の更新に失敗しました', - 'Failed to delete device': 'デバイスの削除に失敗しました', - 'Relay control': 'リレー制御', - 'Two big buttons for each relay. Tap once and wait for the light to change.': - '各リレーに大きなボタンが2つあります。1回タップしてランプが変わるまでお待ちください。', - 'Relay 1': 'リレー1', - 'Relay 2': 'リレー2', - 'Next action available in {seconds}s': '次の操作は{seconds}秒後に利用できます', - 'Checking relay status…': 'リレー状態を確認中…', - 'Checking status…': '状態を確認中…', - 'Currently ON': '現在オンです', - 'Currently OFF': '現在オフです', - ON: 'オン', - OFF: 'オフ', - 'Sending command…': 'コマンド送信中…', - 'Relay {number} {state}': 'リレー{number}{state}', - 'Downlink failed: {reason}': 'ダウンリンクに失敗しました: {reason}', - 'Downlink failed': 'ダウンリンクに失敗しました', - - // CSVDownloadButton translations - select_date_range: '日付範囲を選択', - select_start_end_dates: '開始日と終了日を選択してください', - start_date: '開始日', - end_date: '終了日', - 'Start Date:': '開始日:', - 'End Date:': '終了日:', - current_users: '現在のユーザー', - no_additional_users: '追加のユーザーはいません。', - owner: 'オーナー', - updating: '更新中...', - remove_user: 'ユーザーを削除', - confirm_remove_user: 'このユーザーを削除してもよろしいですか?', - cancel: 'キャンセル', - remove: '削除', - user_removed_success: 'ユーザーが正常に削除されました', - user_remove_error: 'ユーザーの削除中にエラーが発生しました', - permission_update_success: '権限が正常に更新されました', - permission_update_error: '権限の更新に失敗しました', - - // Location settings page - 'Location Settings': 'ロケーション設定', - 'Create Location': 'ロケーションを作成', - 'Add a new location to your account.': 'アカウントに新しいロケーションを追加します。', - 'Location Name': 'ロケーション名', - 'Location Description': 'ロケーションの説明', - 'Describe the location (optional)': 'ロケーションの概要(任意)', - 'Pick a point on the map or adjust the values manually.': - '地図をクリックして地点を選ぶか、値を手動で調整してください。', - 'Use Current Location': '現在地を使用', - 'Locating...': '位置情報を取得中...', - 'We use your browser location to prefill coordinates.': - 'ブラウザの位置情報を利用して座標を自動入力します。', - 'Browser location is unavailable; enter coordinates manually.': - 'ブラウザの位置情報が利用できません。座標を手動で入力してください。', - 'Location Preview': 'ロケーションプレビュー', - 'Click the map to fine-tune the coordinates.': '地図をクリックして座標を微調整してください。', - 'Location Details': 'ロケーション詳細', - 'Edit Details': '詳細を編集', - Description: '説明', - Coordinates: '座標', - 'Not set': '未設定', - 'Update Location': 'ロケーションを更新', - 'Updating...': '更新中...', - 'Add New User': '新しいユーザーを追加', - 'Email Address': 'メールアドレス', - 'Permission Level': '権限レベル', - 'Admin:': '管理者:', - 'Viewer:': '閲覧者:', - 'Apply same permission to all devices in this location': - 'このロケーション内のすべてのデバイスに同じ権限を適用', - 'If unchecked, user will be added with "Disabled" permission to all devices.': - '未チェックの場合、ユーザーはすべてのデバイスで「無効」権限で追加されます。', - 'Adding...': '追加中...', - 'Add User': 'ユーザーを追加', - 'Remove Location': 'ロケーションを削除', - 'Are you sure you want to remove this location? This action cannot be undone.': - 'このロケーションを削除してもよろしいですか?この操作は元に戻せません。', - 'Location details updated successfully': 'ロケーション詳細が正常に更新されました', - 'Failed to update location details': 'ロケーション詳細の更新に失敗しました', - 'User added successfully': 'ユーザーが正常に追加されました', - 'Failed to add user': 'ユーザーの追加に失敗しました', - 'Location removed successfully': 'ロケーションが正常に削除されました', - 'Failed to remove location': 'ロケーションの削除に失敗しました', - 'An error occurred': 'エラーが発生しました', - 'An error occurred while removing the location': 'ロケーションの削除中にエラーが発生しました', - 'No name set': '名前が設定されていません', - 'Full control over location and devices': 'ロケーションとデバイスを完全に管理できます', - 'Can use and configure devices': 'デバイスの使用と設定ができます', - 'Can only view data': 'データの閲覧のみ可能です', - 'No access': 'アクセス不可', - 'No devices found for this location.': 'このロケーションにデバイスが見つかりません。', - 'Add Device': 'デバイスを追加', - '« Back to Location Overview': '« ロケーション概要に戻る', - - // Create Device page - 'Create a New Device': '新しいデバイスを作成', - 'Create a new device to be used in the location {location}.': - '{location} で使用する新しいデバイスを作成します。', - 'Device Name': 'デバイス名', - 'Dev EUI': 'Dev EUI', - Latitude: '緯度', - Longitude: '経度', - 'Device Type': 'デバイスタイプ', - 'Select a device type...': 'デバイスタイプを選択...', - 'User Permissions': 'ユーザー権限', - 'Set All {label}': 'すべて{label}に設定', - 'Set All Admin': 'すべて管理者に設定', - 'Set All User': 'すべてユーザーに設定', - 'Set All Viewer': 'すべて閲覧者に設定', - 'Set All Disabled': 'すべて無効に設定', - 'Creating...': '作成中...', - 'Create Device': 'デバイスを作成', - Notifications: '通知', - Permissions: '権限', - 'Get notified when the device meets a specific condition.': - 'デバイスが特定の条件を満たしたときに通知を受け取ります。', - - // Rules page - 'Add Rule': 'ルールを追加', - 'No rules found.': 'ルールが見つかりません。', - edit: '編集', - Actions: '操作', - 'No recipients': '受信者なし', - 'No criteria defined': '条件が定義されていません', - 'Delete Rule': 'ルールを削除', - 'Are you sure you want to delete this rule? This action cannot be undone.': - 'このルールを削除してもよろしいですか?この操作は元に戻せません。', - 'Deleting...': '削除中...', - 'Edit Rule': 'ルールを編集', - 'Create New Rule': '新しいルールを作成', - 'Rule deleted successfully': 'ルールが正常に削除されました', - 'Failed to delete rule': 'ルールの削除に失敗しました', - 'Create Rule': 'ルールを作成', - 'Update Rule': 'ルールを更新', - 'Rule created successfully': 'ルールが正常に作成されました', - 'Rule updated successfully': 'ルールが正常に更新されました', - 'Failed to create rule': 'ルールの作成に失敗しました', - 'Failed to update rule': 'ルールの更新に失敗しました', - Add: '追加', - Conditions: '条件', - 'Define one or more conditions that will trigger this rule.': - 'このルールを発動させる条件を1つ以上定義してください。', - 'Remove condition': '条件を削除', - Subject: '項目', - Operation: '演算子', - 'Trigger Value': 'トリガー値', - 'Reset Value': 'リセット値', - 'Add Another Condition': '条件を追加', - 'Enter email address': 'メールアドレスを入力', - SMS: 'SMS', - 'Push Notification': 'プッシュ通知', - Method: '通知方法', - Recipients: '受信者', - 'Expand sidebar to search': 'サイドバーを展開して検索', - 'Collapse sidebar': 'サイドバーを折りたたむ', - - // Report page - add_report: 'レポートを追加', - generate_report: 'レポートを生成', - generate_sample_report: 'サンプルレポートを生成', - exporting_data: '{type} データをエクスポート中...', - - // PDF report content - device_report: 'デバイスレポート', - device_report_weekly: '週間', - device_report_monthly: '月間', - report_date_range: 'レポート日付範囲', - generated_at: '作成日時', - generated_by: '作成者', - installed_at: '設置場所', - date: '日付', - created: '作成', - verified: '確認', - approved: '承認', - sampling_size: 'サンプリング数', - date_range: '測定期間', - status: 'ステータス', - normal: '通常', - notice: '通知', - warning: '警告', - alert: 'アラート', - min: '最小', - max: '最大', - avg: '平均', - stddev: '標準偏差', - datetime: '日時', - comment: 'コメント', - sensor_data_trends: 'センサーデータの傾向', - legend: '凡例', - time: '時間', - value: '測定値', - summary: '概要', - data_history: 'データ履歴', - - // Settings page - Unknown: '不明', - EUI: 'EUI', - 'Installed Date': '設置日', - 'Device Location': 'デバイスの場所', - 'Units & Display Settings': '単位と表示設定', - - // Error page - 'Device Error': 'デバイスエラー', - 'There was an error loading the device. Please try again or contact our support team for assistance.': - 'デバイスの読み込み中にエラーが発生しました。再試行するか、サポートチームにご連絡ください。', - 'Back to Devices': 'デバイス一覧に戻る', - 'Contact Support': 'サポートに連絡', - 'Go to Device Settings': 'デバイス設定へ', - 'Authentication Error': '認証エラー', - 'There was a problem with your authentication request. Please try signing in again on the login page. If you continue to experience issues, please contact our support team for additional help.': - '認証リクエストに問題が発生しました。再度ログインページからサインインしてください。問題が解決しない場合は、サポートチームまでご連絡ください。', - 'Go to Login': 'ログインへ', - 'Check your email': 'メールを確認してください', - "We've sent a verification link to": '確認リンクを次のメールアドレスに送信しました', - "We've sent a verification link to your email address": - '確認リンクをメールアドレスに送信しました', - 'Click the link in the email to verify your account and complete your registration.': - 'メール内のリンクをクリックしてアカウントを認証し、登録を完了してください。', - Important: '重要', - 'Please open the verification link on this same device you used to register.': - '登録に使用した同じデバイスで確認リンクを開いてください。', - "Didn't receive an email?": 'メールが届いていませんか?', - 'Check your spam folder or': '迷惑メールフォルダを確認するか', - 'request a new verification email': '新しい確認メールをリクエスト', - 'Return to Login': 'ログインに戻る', - loginErrorMessage: 'ログインエラー:メールアドレスまたはパスワードが正しくありません', - 'Invalid email or password': 'メールアドレスまたはパスワードが正しくありません', - - // Account Settings - 'General Account Settings': '一般アカウント設定', - 'Settings that affect your entire account, including your profile and preferences.': - 'プロフィールや設定を含む、アカウント全体に影響する設定。', - 'Profile Information': 'プロフィール情報', - 'Profile Picture': 'プロフィール画像', - 'Click to upload a new profile picture': '新しいプロフィール画像をアップロードするにはクリック', - 'Your email address cannot be changed': 'メールアドレスは変更できません', - 'Enter your full name': 'フルネームを入力', - 'Enter your username': 'ユーザー名を入力', - 'https://example.com': 'https://example.com', - 'Account Actions': 'アカウントアクション', - 'Need help with your account?': 'アカウントについてお困りですか?', - 'Contact support': 'サポートに連絡', - - // Display Settings - 'Choose your preferred units of measurement and display options for your account.': - 'アカウントの測定単位と表示オプションを選択してください。', - 'Unit Preferences': '単位設定', - 'Display Preferences': '表示設定', - Timezone: 'タイムゾーン', - UTC: 'UTC', - 'Eastern Time (EST)': '東部時間 (EST)', - 'Central Time (CST)': '中部時間 (CST)', - 'Mountain Time (MT)': '山岳時間 (MT)', - 'Pacific Time (PST)': '太平洋時間 (PST)', - 'Greenwich Mean Time (GMT)': 'グリニッジ標準時 (GMT)', - 'Central European Summer Time (CEST)': '中央ヨーロッパ夏時間 (CEST)', - 'Central European Time (CET)': '中央ヨーロッパ時間 (CET)', - 'Indian Standard Time (IST)': 'インド標準時 (IST)', - 'Japan Standard Time (JST)': '日本標準時 (JST)', - 'Australian Eastern Standard Time (AEST)': 'オーストラリア東部標準時 (AEST)', - 'New Zealand Standard Time (NZST)': 'ニュージーランド標準時 (NZST)', - 'Temperature Unit': '温度単位', - Celsius: '摂氏', - Fahrenheit: '華氏', - Kelvin: 'ケルビン', - 'Distance Unit': '距離単位', - Meters: 'メートル', - Millimeters: 'ミリメートル', - Centimetre: 'センチメートル', - Feet: 'フィート', - Inches: 'インチ', - Yards: 'ヤード', - 'EC Unit': 'EC単位', - 'Date Format': '日付形式', - 'DD-MM-YYYY': 'DD-MM-YYYY', - 'MM-DD-YYYY': 'MM-DD-YYYY', - 'YYYY-MM-DD': 'YYYY-MM-DD', - 'DD/MM/YYYY': 'DD/MM/YYYY', - 'MM/DD/YYYY': 'MM/DD/YYYY', - 'YYYY/MM/DD': 'YYYY/MM/DD', - 'DD.MM.YYYY': 'DD.MM.YYYY', - 'MM.DD.YYYY': 'MM.DD.YYYY', - 'YYYY.MM.DD': 'YYYY.MM.DD', - 'ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)': 'ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)', - 'Time Format': '時間形式', - '12-Hour': '12時間制', - '24-Hour': '24時間制', - 'ISO 8601 (HH:mm:ss)': 'ISO 8601 (HH:mm:ss)', - 'Number Format': '数値形式', - 'No Decimal Places': '小数点なし', - 'One Decimal Place': '小数点第1位', - 'Two Decimal Places': '小数点第2位', - 'Three Decimal Places': '小数点第3位', - 'Save Settings': '設定を保存' -}; diff --git a/src/lib/i18n/options.ts b/src/lib/i18n/options.ts new file mode 100644 index 00000000..d00310bb --- /dev/null +++ b/src/lib/i18n/options.ts @@ -0,0 +1,72 @@ +import { m } from '$lib/paraglide/messages.js'; + +export function getPermissionLevelOptions(viewerLabel?: string) { + return [ + { label: m.permission_level_admin(), value: '1' }, + { label: m.permission_level_manager(), value: '2' }, + { label: viewerLabel ?? m.permission_level_user(), value: '3' }, + { label: m.permission_level_disabled(), value: '4' } + ]; +} + +export function getPermissionLevelLabel(value: string | number, viewerLabel?: string): string { + switch (String(value)) { + case '1': + return m.permission_level_admin(); + case '2': + return m.permission_level_manager(); + case '3': + return viewerLabel ?? m.permission_level_user(); + default: + return m.permission_level_disabled(); + } +} + +export function getRuleOperatorOptions() { + return [ + { label: m.rule_operator_greater_than(), value: '>' }, + { label: m.rule_operator_less_than(), value: '<' }, + { label: m.rule_operator_equal_to(), value: '=' }, + { label: m.rule_operator_greater_or_equal(), value: '>=' }, + { label: m.rule_operator_less_or_equal(), value: '<=' }, + { label: m.rule_operator_not_equal(), value: '!=' } + ]; +} + +export function getRuleNotifierTypeOptions() { + return [ + { label: m.rule_notifier_email(), value: '1' }, + { label: m.rule_notifier_sms(), value: '2' }, + { label: m.rule_notifier_push(), value: '3' }, + { label: m.rule_notifier_discord(), value: '4' } + ]; +} + +export function getRuleSendMethodOptions() { + return [ + { label: m.rule_send_email(), value: 'email' }, + { label: m.rule_send_sms(), value: 'sms' }, + { label: m.rule_send_both(), value: 'both' } + ]; +} + +export function getRuleSubjectOptions() { + return [ + { label: m.rule_subject_temperature(), value: 'temperature_c' }, + { label: m.rule_subject_humidity(), value: 'humidity' }, + { label: m.rule_subject_co2(), value: 'co2' }, + { label: m.rule_subject_co(), value: 'co' }, + { label: m.rule_subject_pressure(), value: 'pressure' }, + { label: m.rule_subject_lux(), value: 'lux' }, + { label: m.rule_subject_uv_index(), value: 'uv_index' }, + { label: m.rule_subject_wind_speed(), value: 'wind_speed' }, + { label: m.rule_subject_wind_direction(), value: 'wind_direction' }, + { label: m.rule_subject_rainfall(), value: 'rainfall' }, + { label: m.rule_subject_battery_level(), value: 'battery_level' }, + { label: m.rule_subject_soil_moisture(), value: 'moisture' }, + { label: m.rule_subject_electrical_conductivity(), value: 'ec' }, + { label: m.rule_subject_ph(), value: 'ph' }, + { label: m.rule_subject_water_depth(), value: 'deapth_cm' }, + { label: m.rule_subject_spo2(), value: 'spo2' } + ]; +} diff --git a/src/lib/icons/mdi.ts b/src/lib/icons/mdi.ts deleted file mode 100644 index 203d6225..00000000 --- a/src/lib/icons/mdi.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Local subset of Material Design Icons paths so we avoid bundling the full @mdi/js payload. -// Update this list if additional icons are required. - -export const mdiAlert = 'M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z'; -export const mdiArrowDown = - 'M11,4H13V16L18.5,10.5L19.92,11.92L12,19.84L4.08,11.92L5.5,10.5L11,16V4Z'; -export const mdiArrowDownBold = 'M9,4H15V12H19.84L12,19.84L4.16,12H9V4Z'; -export const mdiArrowRight = - 'M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z'; -export const mdiArrowUp = 'M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z'; -export const mdiArrowUpBold = 'M15,20H9V12H4.16L12,4.16L19.84,12H15V20Z'; -export const mdiCheck = 'M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z'; -export const mdiChevronLeft = 'M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z'; -export const mdiClockOutline = - 'M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z'; -export const mdiClose = - 'M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z'; -export const mdiCog = - 'M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z'; -export const mdiDevices = - 'M3 6H21V4H3C1.9 4 1 4.9 1 6V18C1 19.1 1.9 20 3 20H7V18H3V6M13 12H9V13.78C8.39 14.33 8 15.11 8 16C8 16.89 8.39 17.67 9 18.22V20H13V18.22C13.61 17.67 14 16.88 14 16S13.61 14.33 13 13.78V12M11 17.5C10.17 17.5 9.5 16.83 9.5 16S10.17 14.5 11 14.5 12.5 15.17 12.5 16 11.83 17.5 11 17.5M22 8H16C15.5 8 15 8.5 15 9V19C15 19.5 15.5 20 16 20H22C22.5 20 23 19.5 23 19V9C23 8.5 22.5 8 22 8M21 18H17V10H21V18Z'; -export const mdiDownload = 'M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z'; -export const mdiEarth = - 'M17.9,17.39C17.64,16.59 16.89,16 16,16H15V13A1,1 0 0,0 14,12H8V10H10A1,1 0 0,0 11,9V7H13A2,2 0 0,0 15,5V4.59C17.93,5.77 20,8.64 20,12C20,14.08 19.2,15.97 17.9,17.39M11,19.93C7.05,19.44 4,16.08 4,12C4,11.38 4.08,10.78 4.21,10.21L9,15V16A2,2 0 0,0 11,18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z'; -export const mdiEye = - 'M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z'; -export const mdiEyeOff = - 'M11.83,9L15,12.16C15,12.11 15,12.05 15,12A3,3 0 0,0 12,9C11.94,9 11.89,9 11.83,9M7.53,9.8L9.08,11.35C9.03,11.56 9,11.77 9,12A3,3 0 0,0 12,15C12.22,15 12.44,14.97 12.65,14.92L14.2,16.47C13.53,16.8 12.79,17 12,17A5,5 0 0,1 7,12C7,11.21 7.2,10.47 7.53,9.8M2,4.27L4.28,6.55L4.73,7C3.08,8.3 1.78,10 1,12C2.73,16.39 7,19.5 12,19.5C13.55,19.5 15.03,19.2 16.38,18.66L16.81,19.08L19.73,22L21,20.73L3.27,3M12,7A5,5 0 0,1 17,12C17,12.64 16.87,13.26 16.64,13.82L19.57,16.75C21.07,15.5 22.27,13.86 23,12C21.27,7.61 17,4.5 12,4.5C10.6,4.5 9.26,4.75 8,5.2L10.17,7.35C10.74,7.13 11.35,7 12,7Z'; -export const mdiFileDocument = - 'M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z'; -export const mdiGrid = - 'M10,4V8H14V4H10M16,4V8H20V4H16M16,10V14H20V10H16M16,16V20H20V16H16M14,20V16H10V20H14M8,20V16H4V20H8M8,14V10H4V14H8M8,8V4H4V8H8M10,14H14V10H10V14M4,2H20A2,2 0 0,1 22,4V20A2,2 0 0,1 20,22H4C2.92,22 2,21.1 2,20V4A2,2 0 0,1 4,2Z'; -export const mdiIdentifier = - 'M10 7V9H9V15H10V17H6V15H7V9H6V7H10M16 7C17.11 7 18 7.9 18 9V15C18 16.11 17.11 17 16 17H12V7M16 9H14V15H16V9Z'; -export const mdiMagnify = - 'M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z'; -export const mdiMapMarker = - 'M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z'; -export const mdiMinus = 'M19,13H5V11H19V13Z'; -export const mdiMonitorDashboard = - 'M21,16V4H3V16H21M21,2A2,2 0 0,1 23,4V16A2,2 0 0,1 21,18H14V20H16V22H8V20H10V18H3C1.89,18 1,17.1 1,16V4C1,2.89 1.89,2 3,2H21M5,6H14V11H5V6M15,6H19V8H15V6M19,9V14H15V9H19M5,12H9V14H5V12M10,12H14V14H10V12Z'; -export const mdiPlus = 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z'; -export const mdiSort = - 'M18 21L14 17H17V7H14L18 3L22 7H19V17H22M2 19V17H12V19M2 13V11H9V13M2 7V5H6V7H2Z'; -export const mdiSortAlphabeticalAscending = - 'M19 17H22L18 21L14 17H17V3H19M11 13V15L7.67 19H11V21H5V19L8.33 15H5V13M9 3H7C5.9 3 5 3.9 5 5V11H7V9H9V11H11V5C11 3.9 10.11 3 9 3M9 7H7V5H9Z'; -export const mdiSortAscending = - 'M19 17H22L18 21L14 17H17V3H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z'; -export const mdiSortCalendarAscending = - 'M21 17H24L20 21L16 17H19V3H21V17M8 16H11V13H8V16M13 5H12V3H10V5H6V3H4V5H3C1.89 5 1 5.89 1 7V18C1 19.11 1.89 20 3 20H13C14.11 20 15 19.11 15 18V7C15 5.89 14.11 5 13 5M3 18L3 11H13L13 18L3 18Z'; -export const mdiSortClockAscending = - 'M20 17H23L19 21L15 17H18V3H20V17M8 5C4.14 5 1 8.13 1 12C1 15.87 4.13 19 8 19C11.86 19 15 15.87 15 12C15 8.13 11.87 5 8 5M10.19 14.53L7 12.69V9H8.5V11.82L10.94 13.23L10.19 14.53Z'; -export const mdiSortDescending = - 'M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z'; -export const mdiTune = - 'M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z'; -export const mdiViewDashboard = 'M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z'; -export const mdiViewList = - 'M9,5V9H21V5M9,19H21V15H9M9,14H21V10H9M4,9H8V5H4M4,19H8V15H4M4,14H8V10H4V14Z'; diff --git a/src/lib/images/favicon.svg b/src/lib/images/cropwatch_static.svg similarity index 100% rename from src/lib/images/favicon.svg rename to src/lib/images/cropwatch_static.svg diff --git a/src/lib/images/icons/account_circle.svg b/src/lib/images/icons/account_circle.svg new file mode 100644 index 00000000..4dad5b51 --- /dev/null +++ b/src/lib/images/icons/account_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/active_alert.svg b/src/lib/images/icons/active_alert.svg new file mode 100644 index 00000000..351b2987 --- /dev/null +++ b/src/lib/images/icons/active_alert.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/src/lib/images/icons/add.svg b/src/lib/images/icons/add.svg new file mode 100644 index 00000000..0909479c --- /dev/null +++ b/src/lib/images/icons/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/alert_report.svg b/src/lib/images/icons/alert_report.svg new file mode 100644 index 00000000..03d78b6c --- /dev/null +++ b/src/lib/images/icons/alert_report.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/back.svg b/src/lib/images/icons/back.svg new file mode 100644 index 00000000..39baff34 --- /dev/null +++ b/src/lib/images/icons/back.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/bank.svg b/src/lib/images/icons/bank.svg new file mode 100644 index 00000000..0ad88417 --- /dev/null +++ b/src/lib/images/icons/bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/bar_chart.svg b/src/lib/images/icons/bar_chart.svg new file mode 100644 index 00000000..dd133ba7 --- /dev/null +++ b/src/lib/images/icons/bar_chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/certification.svg b/src/lib/images/icons/certification.svg new file mode 100644 index 00000000..5b6bb1a8 --- /dev/null +++ b/src/lib/images/icons/certification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/check_circle.svg b/src/lib/images/icons/check_circle.svg new file mode 100644 index 00000000..2422e822 --- /dev/null +++ b/src/lib/images/icons/check_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/cloud.svg b/src/lib/images/icons/cloud.svg new file mode 100644 index 00000000..63619a34 --- /dev/null +++ b/src/lib/images/icons/cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/co2.svg b/src/lib/images/icons/co2.svg new file mode 100644 index 00000000..62621099 --- /dev/null +++ b/src/lib/images/icons/co2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/compare_arrows.svg b/src/lib/images/icons/compare_arrows.svg new file mode 100644 index 00000000..dc05a14c --- /dev/null +++ b/src/lib/images/icons/compare_arrows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/dashboard.svg b/src/lib/images/icons/dashboard.svg new file mode 100644 index 00000000..5bd1ea9c --- /dev/null +++ b/src/lib/images/icons/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/device.svg b/src/lib/images/icons/device.svg new file mode 100644 index 00000000..b89f7e5e --- /dev/null +++ b/src/lib/images/icons/device.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/docs.svg b/src/lib/images/icons/docs.svg new file mode 100644 index 00000000..17adae22 --- /dev/null +++ b/src/lib/images/icons/docs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/downArrow.svg b/src/lib/images/icons/downArrow.svg new file mode 100644 index 00000000..b1638fe9 --- /dev/null +++ b/src/lib/images/icons/downArrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/download.svg b/src/lib/images/icons/download.svg new file mode 100644 index 00000000..7dc40fe1 --- /dev/null +++ b/src/lib/images/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/edit.svg b/src/lib/images/icons/edit.svg new file mode 100644 index 00000000..73b48991 --- /dev/null +++ b/src/lib/images/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/email.svg b/src/lib/images/icons/email.svg new file mode 100644 index 00000000..5f2affcb --- /dev/null +++ b/src/lib/images/icons/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/eye.svg b/src/lib/images/icons/eye.svg new file mode 100644 index 00000000..f1d3f51b --- /dev/null +++ b/src/lib/images/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/forgot_shield.svg b/src/lib/images/icons/forgot_shield.svg new file mode 100644 index 00000000..e1f4dfa3 --- /dev/null +++ b/src/lib/images/icons/forgot_shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/forward.svg b/src/lib/images/icons/forward.svg new file mode 100644 index 00000000..b9977410 --- /dev/null +++ b/src/lib/images/icons/forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/globe_location_pin.svg b/src/lib/images/icons/globe_location_pin.svg new file mode 100644 index 00000000..7a5cf978 --- /dev/null +++ b/src/lib/images/icons/globe_location_pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/grid_view.svg b/src/lib/images/icons/grid_view.svg new file mode 100644 index 00000000..037b6906 --- /dev/null +++ b/src/lib/images/icons/grid_view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/history.svg b/src/lib/images/icons/history.svg new file mode 100644 index 00000000..e57dbb7c --- /dev/null +++ b/src/lib/images/icons/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/home.svg b/src/lib/images/icons/home.svg new file mode 100644 index 00000000..34e30f9e --- /dev/null +++ b/src/lib/images/icons/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/key.svg b/src/lib/images/icons/key.svg new file mode 100644 index 00000000..b7b3a9a3 --- /dev/null +++ b/src/lib/images/icons/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/location.svg b/src/lib/images/icons/location.svg new file mode 100644 index 00000000..31934eb8 --- /dev/null +++ b/src/lib/images/icons/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/lock.svg b/src/lib/images/icons/lock.svg new file mode 100644 index 00000000..8bd45130 --- /dev/null +++ b/src/lib/images/icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/logout.svg b/src/lib/images/icons/logout.svg new file mode 100644 index 00000000..416e893e --- /dev/null +++ b/src/lib/images/icons/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/map_search.svg b/src/lib/images/icons/map_search.svg new file mode 100644 index 00000000..66e68bb2 --- /dev/null +++ b/src/lib/images/icons/map_search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/masonary.svg b/src/lib/images/icons/masonary.svg new file mode 100644 index 00000000..00ad3b3b --- /dev/null +++ b/src/lib/images/icons/masonary.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/more_horiz.svg b/src/lib/images/icons/more_horiz.svg new file mode 100644 index 00000000..aa358658 --- /dev/null +++ b/src/lib/images/icons/more_horiz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/more_vert.svg b/src/lib/images/icons/more_vert.svg new file mode 100644 index 00000000..627d1968 --- /dev/null +++ b/src/lib/images/icons/more_vert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/move_location.svg b/src/lib/images/icons/move_location.svg new file mode 100644 index 00000000..f546770b --- /dev/null +++ b/src/lib/images/icons/move_location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/no.svg b/src/lib/images/icons/no.svg new file mode 100644 index 00000000..145e4bb8 --- /dev/null +++ b/src/lib/images/icons/no.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/note_add.svg b/src/lib/images/icons/note_add.svg new file mode 100644 index 00000000..26d60db4 --- /dev/null +++ b/src/lib/images/icons/note_add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/notes_history.svg b/src/lib/images/icons/notes_history.svg new file mode 100644 index 00000000..5de54966 --- /dev/null +++ b/src/lib/images/icons/notes_history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/notifications.svg b/src/lib/images/icons/notifications.svg new file mode 100644 index 00000000..fa12882b --- /dev/null +++ b/src/lib/images/icons/notifications.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/notifications_off.svg b/src/lib/images/icons/notifications_off.svg new file mode 100644 index 00000000..c46dc6a4 --- /dev/null +++ b/src/lib/images/icons/notifications_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/password.svg b/src/lib/images/icons/password.svg new file mode 100644 index 00000000..646b12b3 --- /dev/null +++ b/src/lib/images/icons/password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/password_off.svg b/src/lib/images/icons/password_off.svg new file mode 100644 index 00000000..7a299d20 --- /dev/null +++ b/src/lib/images/icons/password_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/pdf.svg b/src/lib/images/icons/pdf.svg new file mode 100644 index 00000000..6ca0d60f --- /dev/null +++ b/src/lib/images/icons/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/person_add.svg b/src/lib/images/icons/person_add.svg new file mode 100644 index 00000000..4c0c0750 --- /dev/null +++ b/src/lib/images/icons/person_add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/plant.svg b/src/lib/images/icons/plant.svg new file mode 100644 index 00000000..079bfb81 --- /dev/null +++ b/src/lib/images/icons/plant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/power.svg b/src/lib/images/icons/power.svg new file mode 100644 index 00000000..12a3ca23 --- /dev/null +++ b/src/lib/images/icons/power.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/power_off.svg b/src/lib/images/icons/power_off.svg new file mode 100644 index 00000000..a5f6e375 --- /dev/null +++ b/src/lib/images/icons/power_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/receipt.svg b/src/lib/images/icons/receipt.svg new file mode 100644 index 00000000..7827edc6 --- /dev/null +++ b/src/lib/images/icons/receipt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/refresh.svg b/src/lib/images/icons/refresh.svg new file mode 100644 index 00000000..0c7ee907 --- /dev/null +++ b/src/lib/images/icons/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/report.svg b/src/lib/images/icons/report.svg new file mode 100644 index 00000000..834bf0e9 --- /dev/null +++ b/src/lib/images/icons/report.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/router.svg b/src/lib/images/icons/router.svg new file mode 100644 index 00000000..09975e2b --- /dev/null +++ b/src/lib/images/icons/router.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/rules.svg b/src/lib/images/icons/rules.svg new file mode 100644 index 00000000..d4fc9735 --- /dev/null +++ b/src/lib/images/icons/rules.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/save.svg b/src/lib/images/icons/save.svg new file mode 100644 index 00000000..bb94bdbe --- /dev/null +++ b/src/lib/images/icons/save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/sensor_cards.svg b/src/lib/images/icons/sensor_cards.svg new file mode 100644 index 00000000..426b2be6 --- /dev/null +++ b/src/lib/images/icons/sensor_cards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/settings.svg b/src/lib/images/icons/settings.svg new file mode 100644 index 00000000..b7e510f2 --- /dev/null +++ b/src/lib/images/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/spam.svg b/src/lib/images/icons/spam.svg new file mode 100644 index 00000000..54092040 --- /dev/null +++ b/src/lib/images/icons/spam.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/lib/images/icons/table.svg b/src/lib/images/icons/table.svg new file mode 100644 index 00000000..f892ff90 --- /dev/null +++ b/src/lib/images/icons/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/trash.svg b/src/lib/images/icons/trash.svg new file mode 100644 index 00000000..ee54e0e2 --- /dev/null +++ b/src/lib/images/icons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/water.svg b/src/lib/images/icons/water.svg new file mode 100644 index 00000000..ea27ec10 --- /dev/null +++ b/src/lib/images/icons/water.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/images/icons/you.svg b/src/lib/images/icons/you.svg new file mode 100644 index 00000000..10a0a13d --- /dev/null +++ b/src/lib/images/icons/you.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6c..00000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/interfaces/CwDevice.interface.ts b/src/lib/interfaces/CwDevice.interface.ts new file mode 100644 index 00000000..9c20c379 --- /dev/null +++ b/src/lib/interfaces/CwDevice.interface.ts @@ -0,0 +1,27 @@ +export interface CwDevice { + dev_eui: string; + name: string; + type?: number | null; + upload_interval?: number | null; + lat?: number | null; + long?: number | null; + installed_at?: string | null; + battery_changed_at?: string | null; + user_id?: string | null; + warranty_start_date?: string | null; + sensor1_serial?: string | null; + sensor2_serial?: string | null; + sensor_serial?: string | null; + location_id?: number | null; + report_endpoint?: string | null; + battery_level?: number | null; + last_data_updated_at?: string | null; + tti_name?: string | null; + primary_data?: string | null; + secondary_data?: string | null; + group?: string | null; + created_at?: string | null; + location_name?: string; + has_primary_data?: boolean; + [key: string]: unknown; +} diff --git a/src/lib/interfaces/CwDeviceOwner.interface.ts b/src/lib/interfaces/CwDeviceOwner.interface.ts new file mode 100644 index 00000000..d4563d23 --- /dev/null +++ b/src/lib/interfaces/CwDeviceOwner.interface.ts @@ -0,0 +1,10 @@ +export interface CwDeviceOwner { + dev_eui: string; + user_id: string; + permission_level: number; + id?: number; + owner_id?: number | null; + user_email?: string; + targetUserEmail?: string; + [key: string]: unknown; +} diff --git a/src/lib/interfaces/CwDeviceTypeDto.interface.ts b/src/lib/interfaces/CwDeviceTypeDto.interface.ts new file mode 100644 index 00000000..c91f6a7e --- /dev/null +++ b/src/lib/interfaces/CwDeviceTypeDto.interface.ts @@ -0,0 +1,26 @@ +export interface CwDeviceType { + id: number; + name: string; + model: string; + decoder?: string | null; + is_active?: boolean | null; + isActive?: boolean | null; + created_at?: string | null; + data_table?: string | null; + manufacturer?: string | null; + primary_data?: string | null; + data_table_v2?: string | null; + secondary_data?: string | null; + primary_data_v2?: string | null; + primary_divider?: number | null; + secondary_data_v2?: string | null; + secondary_divider?: number | null; + tti_application_id?: string | null; + TTI_application_id?: string | null; + primary_multiplier?: number | null; + secondary_multiplier?: number | null; + primary_data_notation?: string | null; + default_upload_interval?: number | null; + secondary_data_notation?: string | null; + [key: string]: unknown; +} diff --git a/src/lib/interfaces/IAirDataService.ts b/src/lib/interfaces/IAirDataService.ts deleted file mode 100644 index 6382511f..00000000 --- a/src/lib/interfaces/IAirDataService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AirData, AirDataInsert, AirDataUpdate } from '../models/AirData'; - -/** - * Service interface for air data operations - */ -export interface IAirDataService { - /** - * Get air data by device EUI - * @param devEui The device EUI - */ - getAirDataByDevice(devEui: string): Promise; - - /** - * Get latest air data for a device - * @param devEui The device EUI - */ - getLatestAirDataByDevice(devEui: string): Promise; - - /** - * Get air data within a date range - * @param devEui The device EUI - * @param startDate The start date - * @param endDate The end date - */ - getAirDataByDateRange(devEui: string, startDate: Date, endDate: Date): Promise; - - /** - * Create a new air data record - * @param airData The air data record to create - */ - createAirData(airData: AirDataInsert): Promise; -} \ No newline at end of file diff --git a/src/lib/interfaces/IAlertPoint.ts b/src/lib/interfaces/IAlertPoint.ts deleted file mode 100644 index dc2d20b2..00000000 --- a/src/lib/interfaces/IAlertPoint.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface IAlertPoint { - id: number; - name: string; - operator?: '>' | '<' | '='; - value?: number; - min?: number; - max?: number; - color: string; -}; \ No newline at end of file diff --git a/src/lib/interfaces/IAuthService.ts b/src/lib/interfaces/IAuthService.ts deleted file mode 100644 index 6518b8f2..00000000 --- a/src/lib/interfaces/IAuthService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Service interface for authentication operations - */ -export interface IAuthService { - /** - * Sign in with email and password - * @param email User email - * @param password User password - * @returns Promise with the session or null if authentication failed - */ - signInWithPassword(email: string, password: string): Promise<{ user: any; session: any } | null>; - - /** - * Sign out the current user - * @returns Promise indicating success - */ - signOut(): Promise; - - /** - * Get the current session - * @returns Promise with the session or null if not authenticated - */ - getSession(): Promise<{ user: any; session: any } | null>; - - /** - * Register a new user - * @param userData User registration data including email, password, and profile information - * @returns Promise with the registration result - */ - register(userData: { - email: string; - password: string; - firstName: string; - lastName: string; - company: string; - }): Promise<{ - success: boolean; - error?: string; - emailConfirmationRequired?: boolean; - }>; - - /** - * Send password reset email to user - * @param email Email address to send reset link to - * @returns Promise with success status and any error message - */ - resetPassword(email: string): Promise<{ success: boolean; error?: string }>; - - /** - * Update user password after reset - * @param newPassword New password to set - * @returns Promise with success status and any error message - */ - updatePassword(newPassword: string): Promise<{ success: boolean; error?: string }>; -} \ No newline at end of file diff --git a/src/lib/interfaces/IDeviceDataService.ts b/src/lib/interfaces/IDeviceDataService.ts deleted file mode 100644 index acb6e71d..00000000 --- a/src/lib/interfaces/IDeviceDataService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ReportAlertPoint } from '../models/Report'; -import type { DeviceType } from '../models/Device'; -import type { DeviceDataRecord } from '../models/DeviceDataRecord'; - -/** - * Interface for dynamic device data retrieval based on device type - */ -export interface IDeviceDataService { - /** - * Get the latest data for a device based on its type - * @param devEui The device EUI - */ - getLatestDeviceData(devEui: string): Promise; - - /** - * Get device data within a date range based on device type - * @param devEui The device EUI - * @param startDate The start date in user's timezone - * @param endDate The end date in user's timezone - * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') - */ - getDeviceDataByDateRange( - devEui: string, - startDate: Date, - endDate: Date, - timezone?: string - ): Promise; - - /** - * Get device data within a date range based on device type as CSV - * @param devEui The device EUI - * @param startDate The start date in user's timezone - * @param endDate The end date in user's timezone - * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') - */ - getDeviceDataByDateRangeAsCSV( - devEui: string, - startDate: Date, - endDate: Date, - timezone?: string - ): Promise; - - /** - * Get device data for report with optional filtering - * @param params.devEui The device EUI - * @param params.startDate The start date - * @param params.endDate The end date - * @param params.timezone The timezone - * @param params.intervalMinutes The interval in minutes - * @param params.columns Optional columns to filter - * @param params.ops Optional operators for filtering - * @param params.mins Optional minimum values - * @param params.maxs Optional maximum values - */ - getDeviceDataForReport({ - devEui, - startDate, - endDate, - timezone, - intervalMinutes, - columns, - ops, - mins, - maxs - }: { - devEui: string; - startDate: Date; - endDate: Date; - timezone: string; - intervalMinutes: number; - columns?: string[]; - ops?: string[]; - mins?: number[]; - maxs?: (number | null)[]; - }): Promise; - - /** - * Get alert points for a device from its reports - * @param devEui The device EUI - */ - getAlertPointsForDevice(devEui: string): Promise; -} diff --git a/src/lib/interfaces/IDeviceService.ts b/src/lib/interfaces/IDeviceService.ts deleted file mode 100644 index 98c14a7e..00000000 --- a/src/lib/interfaces/IDeviceService.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Device, DeviceInsert, DeviceUpdate, DeviceWithType } from '../models/Device'; -import type { DeviceWithJoins } from '../repositories/DeviceRepository'; - -/** - * Service interface for device operations - */ -export interface IDeviceService { - /** - * Get a device by its EUI - * @param devEui The device EUI - */ - getDeviceByEui(devEui: string): Promise; - - /** - * Get a device with its type information - * @param devEui The device EUI - */ - getDeviceWithTypeByEui(devEui: string): Promise; - - /** - * Get all devices - */ - getAllDevices(user_id: string): Promise; - - /** - * Get devices by location ID - * @param locationId The location ID - */ - getDevicesByLocation(locationId: number): Promise; - - /** - * Get devices by type ID - * @param typeId The device type ID - */ - getDevicesByType(typeId: number): Promise; - - /** - * Create a new device - * @param device The device to create - */ - createDevice(device: DeviceInsert): Promise; - - /** - * Update an existing device - * @param devEui The device EUI - * @param device The device with updated values - */ - updateDevice(devEui: string, device: DeviceUpdate): Promise; - - /** - * Delete a device - * @param devEui The device EUI - */ - deleteDevice(devEui: string): Promise; -} diff --git a/src/lib/interfaces/ILocationService.ts b/src/lib/interfaces/ILocationService.ts deleted file mode 100644 index 37598be9..00000000 --- a/src/lib/interfaces/ILocationService.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { Location, LocationInsert, LocationUpdate } from '../models/Location'; -import type { LocationUser } from '../models/LocationUser'; - -/** - * Service interface for location operations - */ -export interface ILocationService { - /** - * Get a location by its ID - * @param locationId The location ID - */ - getLocationById(locationId: number): Promise; - - /** - * Get all locations - */ - getAllLocations(): Promise; - - /** - * Get locations by owner ID - * @param ownerId The owner ID - */ - getLocationsByOwner(ownerId: string): Promise; - - /** - * Create a new location - * @param location The location to create - */ - createLocation(location: LocationInsert): Promise; - - /** - * Update an existing location - * @param locationId The location ID - * @param location The location with updated values - */ - updateLocation(locationId: number, location: LocationUpdate): Promise; - - /** - * Delete a location - * @param locationId The location ID - */ - deleteLocation(locationId: number, userId: string): Promise; - - /** - * Get the count of devices for a specific location - * @param locationId The location ID - */ - getDeviceCountForLocation(locationId: number): Promise; - - /** - * Get all users with access to a location - * @param locationId The location ID - */ - getLocationUsers(locationId: number): Promise; - - /** - * Check if a user has sufficient permissions to access a location - * @param locationId The location ID - * @param userId The user ID - * @param requiredLevel The minimum required permission level (defaults to 3 - Viewer) - */ - checkUserLocationPermission( - locationId: number, - userId: string, - requiredLevel?: number - ): Promise; - - /** - * Add a user to a location with specified permission level - * @param locationId The location ID - * @param email The email of the user to add - * @param permissionLevel The permission level to assign - * @param applyToDevices Whether to apply the same permission to all devices in the location - */ - addUserToLocation( - locationId: number, - email: string, - permissionLevel: number, - applyToDevices: boolean - ): Promise<{ success: boolean; error?: string; warning?: string }>; - - /** - * Update a user's permission for a location - * @param locationId The location ID - * @param userId The user ID to update - * @param locationOwnerId The location owner entry ID - * @param permissionLevel The new permission level - * @param applyToDevices Whether to apply the same permission to all devices in the location - */ - updateUserPermission( - locationId: number, - userId: string, - locationOwnerId: number, - permissionLevel: number, - applyToDevices: boolean - ): Promise<{ success: boolean; error?: string; warning?: string }>; - - /** - * Remove a user from a location and its devices - * @param locationId The location ID - * @param userId The user ID to remove - * @param locationOwnerId The location owner entry ID - */ - removeUserFromLocation( - locationId: number, - userId: string, - locationOwnerId: number, - me: string - ): Promise<{ success: boolean; error?: string }>; -} diff --git a/src/lib/interfaces/IReportService.ts b/src/lib/interfaces/IReportService.ts deleted file mode 100644 index 41d953f9..00000000 --- a/src/lib/interfaces/IReportService.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { - Report, - ReportInsert, - ReportUpdate, - ReportWithDetails, - ReportWithRecipients, - ReportAlertPoint, - ReportAlertPointInsert, - ReportAlertPointUpdate, - ReportRecipient, - ReportRecipientInsert, - ReportRecipientUpdate, - ReportUserSchedule, - ReportUserScheduleInsert, - ReportUserScheduleUpdate -} from '../models/Report'; - -/** - * Service interface for report operations - */ -export interface IReportService { - /** - * Get reports by device EUI - */ - getReportsByDeviceEui(devEui: string): Promise; - - /** - * Get report by report ID - */ - getReportByReportId(reportId: string): Promise; - - /** - * Get reports with full details by device EUI - */ - getReportsWithDetailsByDeviceEui(devEui: string): Promise; - - /** - * Get reports with recipients by device EUI - */ - getReportsWithRecipientsByDeviceEui(devEui: string): Promise; - - /** - * Create a new report - */ - createReport(report: ReportInsert): Promise; - - /** - * Update a report - */ - updateReport(reportId: string, updates: ReportUpdate): Promise; - - /** - * Upsert a report - */ - upsertReport(report: ReportInsert): Promise; - - /** - * Delete a report and all related data - */ - deleteReport(reportId: string): Promise; - - /** - * Count reports for a device - */ - countReportsByDeviceEui(devEui: string): Promise; - - /** - * Get alert points for a report - */ - getAlertPointsByReportId(reportId: string): Promise; - - /** - * Create an alert point - */ - createAlertPoint(alertPoint: ReportAlertPointInsert): Promise; - - /** - * Update an alert point - */ - updateAlertPoint(id: number, updates: ReportAlertPointUpdate): Promise; - - /** - * Upsert an alert point - */ - upsertAlertPoint(alertPoint: ReportAlertPointInsert): Promise; - - /** - * Delete alert points for a report - */ - deleteAlertPointsByReportId(reportId: string): Promise; - - /** - * Get recipients for a report - */ - getRecipientsByReportId(reportId: string): Promise; - - /** - * Create a recipient - */ - createRecipient(recipient: ReportRecipientInsert): Promise; - - /** - * Update a recipient - */ - updateRecipient(id: number, updates: ReportRecipientUpdate): Promise; - - /** - * Upsert a recipient - */ - upsertRecipient(recipient: ReportRecipientInsert): Promise; - - /** - * Delete recipients for a report - */ - deleteRecipientsByReportId(reportId: string): Promise; - - /** - * Delete a specific recipient - */ - deleteRecipient(id: number): Promise; - - /** - * Get schedules for a report - */ - getSchedulesByReportId(reportId: string): Promise; - - /** - * Get schedules by device EUI - */ - getSchedulesByDeviceEui(devEui: string): Promise; - - /** - * Get active schedules for a user - */ - getActiveSchedulesByUserId(userId: string): Promise; - - /** - * Create a schedule - */ - createSchedule(schedule: ReportUserScheduleInsert): Promise; - - /** - * Update a schedule - */ - updateSchedule(id: number, updates: ReportUserScheduleUpdate): Promise; - - /** - * Upsert a schedule - */ - upsertSchedule(schedule: ReportUserScheduleInsert): Promise; - - /** - * Delete schedules for a report - */ - deleteSchedulesByReportId(reportId: string): Promise; - - /** - * Activate/deactivate a schedule - */ - setScheduleActive(id: number, isActive: boolean): Promise; -} diff --git a/src/lib/interfaces/IRepository.ts b/src/lib/interfaces/IRepository.ts deleted file mode 100644 index 9ba17d58..00000000 --- a/src/lib/interfaces/IRepository.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Generic repository interface that defines standard CRUD operations - * T represents the entity type - * K represents the primary key type - */ -export interface IRepository { - /** - * Find an entity by its primary key - * @param id The primary key value - */ - findById(id: K): Promise; - - /** - * Get all entities - */ - findAll(): Promise; - - /** - * Create a new entity - * @param entity The entity to create - */ - create(entity: I): Promise; - - /** - * Update an existing entity - * @param id The primary key value - * @param entity The entity with updated values - */ - update(id: K, entity: U): Promise; - - /** - * Delete an entity by its primary key - * @param id The primary key value - */ - delete(id: K): Promise; -} \ No newline at end of file diff --git a/src/lib/interfaces/IRuleService.ts b/src/lib/interfaces/IRuleService.ts deleted file mode 100644 index 15fe3102..00000000 --- a/src/lib/interfaces/IRuleService.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - Rule, - RuleInsert, - RuleUpdate, - RuleCriteria, - RuleCriteriaInsert, - RuleCriteriaUpdate, - RuleWithCriteria -} from '../models/Rule'; - -/** - * Service interface for rule operations - */ -export interface IRuleService { - /** - * Get a rule by its ID - * @param id The rule ID - */ - getRuleById(id: number): Promise; - - /** - * Get a rule with its criteria by group ID - * @param ruleGroupId The rule group ID - */ - getRuleWithCriteria(ruleGroupId: string): Promise; - - /** - * Get rules by device EUI - * @param devEui The device EUI - */ - getRulesByDevice(devEui: string): Promise; - - /** - * Get rules and rule criteria by device EUI - * @param devEui The device EUI - */ - getRulesAndCriteriaByDevice(devEui: string): Promise; - - /** - * Get rules by profile ID - * @param profileId The profile ID - */ - getRulesByProfile(profileId: string): Promise; - - /** - * Get rule criteria by rule group ID - * @param ruleGroupId The rule group ID - */ - getRuleCriteriaByGroup(ruleGroupId: string): Promise; - - /** - * Create a new rule - * @param rule The rule to create - */ - createRule(rule: RuleInsert): Promise; - - /** - * Create a new rule criteria - * @param criteria The rule criteria to create - */ - createRuleCriteria(criteria: RuleCriteriaInsert): Promise; - - /** - * Create a complete rule with criteria - * @param rule The rule to create - * @param criteria Array of criteria to create for the rule - */ - createRuleWithCriteria( - rule: RuleInsert, - criteria: RuleCriteriaInsert[] - ): Promise; - - /** - * Update an existing rule - * @param id The rule ID - * @param rule The rule with updated values - */ - updateRule(id: number, rule: RuleUpdate): Promise; - - /** - * Update an existing rule criteria - * @param id The criteria ID - * @param criteria The criteria with updated values - */ - updateRuleCriteria(id: number, criteria: RuleCriteriaUpdate): Promise; - - /** - * Delete a rule and its associated criteria - * @param id The rule ID - */ - deleteRule(id: number): Promise; - - /** - * Delete a rule criteria - * @param id The criteria ID - */ - deleteRuleCriteria(id: number): Promise; -} diff --git a/src/lib/interfaces/ISessionService.ts b/src/lib/interfaces/ISessionService.ts deleted file mode 100644 index b59661c1..00000000 --- a/src/lib/interfaces/ISessionService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Session, User } from '@supabase/supabase-js'; - -/** - * Interface for session management service - * Provides safe access to authenticated sessions - */ -export interface ISessionService { - /** - * Gets a validated session and user - * Similar to safeGetSession in hooks.server.ts - * Ensures JWT is valid before returning session - */ - getSafeSession(): Promise<{ session: Session | null; user: User | null }>; -} diff --git a/src/lib/interfaces/ISoilDataService.ts b/src/lib/interfaces/ISoilDataService.ts deleted file mode 100644 index 67bfae71..00000000 --- a/src/lib/interfaces/ISoilDataService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { SoilData, SoilDataInsert } from '../models/SoilData'; - -/** - * Service interface for soil data operations - */ -export interface ISoilDataService { - /** - * Get soil data by device EUI - * @param devEui The device EUI - */ - getSoilDataByDevice(devEui: string): Promise; - - /** - * Get latest soil data for a device - * @param devEui The device EUI - */ - getLatestSoilDataByDevice(devEui: string): Promise; - - /** - * Get soil data within a date range - * @param devEui The device EUI - * @param startDate The start date - * @param endDate The end date - */ - getSoilDataByDateRange(devEui: string, startDate: Date, endDate: Date): Promise; - - /** - * Create a new soil data record - * @param soilData The soil data record to create - */ - createSoilData(soilData: SoilDataInsert): Promise; -} \ No newline at end of file diff --git a/src/lib/interfaces/PdfFile.interface.ts b/src/lib/interfaces/PdfFile.interface.ts new file mode 100644 index 00000000..d17ad33b --- /dev/null +++ b/src/lib/interfaces/PdfFile.interface.ts @@ -0,0 +1,18 @@ +export interface FileMetadata { + eTag: string; + size: number; + mimetype: string; + cacheControl: string; + lastModified: string; // ISO datetime + contentLength: number; + httpStatusCode: number; +} + +export interface PdfFile { + name: string; + id: string; // UUID + updated_at: string; // ISO datetime + created_at: string; // ISO datetime + last_accessed_at: string; // ISO datetime + metadata: FileMetadata; +} \ No newline at end of file diff --git a/src/lib/interfaces/device.interface.ts b/src/lib/interfaces/device.interface.ts new file mode 100644 index 00000000..71b8477e --- /dev/null +++ b/src/lib/interfaces/device.interface.ts @@ -0,0 +1,22 @@ +export interface IDevice { + dev_eui: string; + name: string; + location_name: string; + group?: string; + data_table?: string; + created_at: Date; + has_primary_data?: boolean; + co2: number; + humidity: number; + temperature_c: number; + soil_temperature_c?: number | null; + soil_humidity?: number | null; + cwloading?: boolean; + location_id: number; + alert_count?: number; + + /** cw_device_type.id FK – used to look up device-type config */ + device_type_id?: number; + /** Full raw sensor payload so dynamic keys can be resolved */ + raw_data?: Record; +} diff --git a/src/lib/interfaces/deviceDisplay.ts b/src/lib/interfaces/deviceDisplay.ts new file mode 100644 index 00000000..34485426 --- /dev/null +++ b/src/lib/interfaces/deviceDisplay.ts @@ -0,0 +1,61 @@ +import type { Component, SvelteComponent } from 'svelte'; +import type { + PendingRelayStates, + RelayNumber, + RelayTargetState +} from '$lib/devices/relay-types'; + +/** + * Common props contract that every device display component must accept. + * + * Display components own their own layout, chart config, column definitions, + * and formatting — the dispatcher page passes this uniform shape and each + * component decides how to render it. + */ +export interface DeviceDisplayProps { + /** The device EUI identifier. */ + devEui: string; + + /** Location id the device belongs to. */ + locationId: string; + + /** Human-readable location name (for display & CSV filenames). */ + locationName: string; + + /** Most recent telemetry record (may be null before the first fetch resolves). */ + latestData: Record | null; + + /** + * Historical telemetry rows for the selected date range. + * Shape varies per table — each display component knows its own columns. + */ + historicalData: Record[]; + + /** Whether historical data is currently being fetched. */ + loading: boolean; + + /** Effective permission level for the current user on this device. */ + permissionLevel: number; + + /** Relay confirmation state keyed by relay number. Relay-only. */ + pendingRelayStates?: PendingRelayStates; + + /** Relay downlink action callback. Relay-only. */ + queueRelayCommand?: ( + relay: RelayNumber, + targetState: RelayTargetState, + durationSeconds?: number + ) => Promise; + + /** JWT auth token forwarded for any client-side API calls. */ + authToken: string | null; +} + +/** + * A Svelte component constructor that accepts {@link DeviceDisplayProps}. + * + * Uses a permissive type to avoid friction with Svelte's internal component + * typing across different versions. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DeviceDisplayComponent = Component; diff --git a/src/lib/interfaces/jwt.interface.ts b/src/lib/interfaces/jwt.interface.ts new file mode 100644 index 00000000..3e4d1850 --- /dev/null +++ b/src/lib/interfaces/jwt.interface.ts @@ -0,0 +1,39 @@ +export interface IJWT { + iss: string + sub: string + aud: string + exp: number + iat: number + email: string + phone: string + app_metadata: AppMetadata + user_metadata: UserMetadata + role: string + aal: string + amr: Amr[] + session_id: string + is_anonymous: boolean +} + +export interface AppMetadata { + provider: string + providers: string[] +} + +export interface UserMetadata { + avatar_url: string + email: string + email_verified: boolean + full_name: string + iss: string + name: string + phone_verified: boolean + picture: string + provider_id: string + sub: string +} + +export interface Amr { + method: string + timestamp: number +} diff --git a/src/lib/interfaces/profile.interface.ts b/src/lib/interfaces/profile.interface.ts new file mode 100644 index 00000000..b7fa3fa7 --- /dev/null +++ b/src/lib/interfaces/profile.interface.ts @@ -0,0 +1,7 @@ +export interface Profile { + username: string; + full_name: string; + website: string; + employer: string; + phone_number: string; +} diff --git a/src/lib/interfaces/rule.interface.ts b/src/lib/interfaces/rule.interface.ts new file mode 100644 index 00000000..6d8ca90a --- /dev/null +++ b/src/lib/interfaces/rule.interface.ts @@ -0,0 +1,29 @@ +export interface RulesDto { + id: number; + name: string; + action_recipient: string; + notifier_type: number; + ruleGroupId: string; + profile_id: string; + dev_eui: string; + send_using: string; + is_triggered: boolean; + trigger_count: number; + created_at: Date; + last_triggered: Date | null; + cw_rule_criteria: CwRuleCriterum[]; +} + +export type IRule = RulesDto; + +export interface CwRuleCriterum { + created_at: Date; + criteria_id: string; + id: number; + operator: string; + parent_id: string; + reset_value: number; + ruleGroupId: string; + subject: string; + trigger_value: number; +} diff --git a/src/lib/interfaces/telemetryRow.ts b/src/lib/interfaces/telemetryRow.ts new file mode 100644 index 00000000..6fcd24b8 --- /dev/null +++ b/src/lib/interfaces/telemetryRow.ts @@ -0,0 +1,10 @@ +export interface TelemetryRow { + id: string; + created_at: string; + temperature_c: number; + humidity: number; + co2: number; + alertRaised?: boolean; + hasNotes?: boolean; + notes: any[]; +} \ No newline at end of file diff --git a/src/lib/lorawan/dragino.ts b/src/lib/lorawan/dragino.ts deleted file mode 100644 index b656936d..00000000 --- a/src/lib/lorawan/dragino.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const DRAGINO_LT22222L_PAYLOADS = { - relay1On: 'AwER', // 030111 - relay1Off: 'AwAR', // 030011 - relay2On: 'AxEB', // 031101 - relay2Off: 'AxEA' // 031100 -}; diff --git a/src/lib/models/AirData.ts b/src/lib/models/AirData.ts deleted file mode 100644 index f427998c..00000000 --- a/src/lib/models/AirData.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Represents air data from the database - */ -export type AirData = Database['public']['Tables']['cw_air_data']['Row']; - -/** - * Type for creating new air data - */ -export type AirDataInsert = Database['public']['Tables']['cw_air_data']['Insert']; - -/** - * Type for updating existing air data - */ -export type AirDataUpdate = Database['public']['Tables']['cw_air_data']['Update']; \ No newline at end of file diff --git a/src/lib/models/Device.ts b/src/lib/models/Device.ts deleted file mode 100644 index 72247de2..00000000 --- a/src/lib/models/Device.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Represents a device entity from the database - */ -export type Device = Database['public']['Tables']['cw_devices']['Row']; - -/** - * Type for creating a new device - */ -export type DeviceInsert = Database['public']['Tables']['cw_devices']['Insert']; - -/** - * Type for updating an existing device - */ -export type DeviceUpdate = Database['public']['Tables']['cw_devices']['Update']; - -/** - * Type for device with detailed device type information - */ -export interface DeviceWithType extends Device { - cw_device_type?: DeviceType; - tti_name?: string | null; -} - -/** - * Type for device location - */ -export type DeviceLocation = Database['public']['Tables']['cw_locations']['Row']; - -/** - * Type for device type - */ -export type DeviceType = Database['public']['Tables']['cw_device_type']['Row']; diff --git a/src/lib/models/DeviceDataRecord.ts b/src/lib/models/DeviceDataRecord.ts deleted file mode 100644 index 52787624..00000000 --- a/src/lib/models/DeviceDataRecord.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface DeviceDataRecord { - dev_eui: string; - created_at: string; - [key: string]: unknown; -} - -export type DeviceStats = Record< - string, - { - min: number; - max: number; - avg: number; - median: number; - stdDev: number; - count: number; - lastReading: number; - trend: 'up' | 'down' | 'stable' | null; - } ->; diff --git a/src/lib/models/DeviceOwner.ts b/src/lib/models/DeviceOwner.ts deleted file mode 100644 index 3105f94b..00000000 --- a/src/lib/models/DeviceOwner.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Represents a device owner entity from the database - */ -export type DeviceOwner = Database['public']['Tables']['cw_device_owners']['Row']; - -/** - * Type for creating a new device owner - */ -export type DeviceOwnerInsert = Database['public']['Tables']['cw_device_owners']['Insert']; - -/** - * Type for updating an existing device owner - */ -export type DeviceOwnerUpdate = Database['public']['Tables']['cw_device_owners']['Update']; - -/** - * Type for device owner with user profile information - */ -export interface DeviceOwnerWithProfile extends DeviceOwner { - profile?: { - id: string; - full_name?: string; - email?: string; - username?: string; - }; -} diff --git a/src/lib/models/Gateway.ts b/src/lib/models/Gateway.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/models/Location.ts b/src/lib/models/Location.ts deleted file mode 100644 index 8d89b2ea..00000000 --- a/src/lib/models/Location.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Location model representing locations from the cw_locations table - */ -export interface Location { - /** - * Unique identifier for the location - */ - location_id: number; - - /** - * Name of the location - */ - name: string; - - /** - * Optional description of the location - */ - description?: string | null; - - /** - * Latitude coordinate - */ - lat?: number | null; - - /** - * Longitude coordinate - */ - long?: number | null; - - /** - * Owner ID of the location - */ - owner_id?: string | null; - - /** - * Timestamp when the location was created - */ - created_at: string; - - /** - * Map zoom level for display purposes - */ - map_zoom?: number | null; -} - -/** - * Data required to insert a new location - */ -export type LocationInsert = Omit; - -/** - * Type for updating existing location - */ -export type LocationUpdate = Partial; \ No newline at end of file diff --git a/src/lib/models/LocationUser.ts b/src/lib/models/LocationUser.ts deleted file mode 100644 index 8d423089..00000000 --- a/src/lib/models/LocationUser.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Represents a user with permission to a location - */ -export interface LocationUser { - /** - * Unique identifier for the location owner entry - */ - id: number; - - /** - * User ID of the user - */ - user_id: string; - - /** - * Location ID this permission applies to - */ - location_id: number; - - /** - * Permission level (1: Admin, 2: User, 3: Viewer, 4: Disabled) - */ - permission_level: number | null; - - /** - * Whether the permission is active - */ - is_active: boolean | null; - - /** - * Description for this permission - */ - description: string | null; - - /** - * User profile information - */ - profile?: { - /** - * User's full name - */ - full_name: string | null; - - /** - * User's email - */ - email: string | null; - - /** - * User's username - */ - username: string | null; - }; -} - -/** - * Permission levels for locations and devices - */ -export enum PermissionLevel { - /** - * Full control over the location/device, including managing users - */ - Admin = 1, - - /** - * Can use and configure the location/device, but can't manage users - */ - User = 2, - - /** - * Can only view the location/device data - */ - Viewer = 3, - - /** - * No access to the location/device - */ - Disabled = 4 -} \ No newline at end of file diff --git a/src/lib/models/NotifierType.ts b/src/lib/models/NotifierType.ts deleted file mode 100644 index 6b8288f7..00000000 --- a/src/lib/models/NotifierType.ts +++ /dev/null @@ -1,27 +0,0 @@ - -/** - * NotifierType model representing notification types from the cw_notifier_types table - */ -export interface NotifierType { - /** Unique identifier */ - id: number; - - /** Identifier used by related rules */ - notifier_id: number; - - /** Human readable name */ - name: string; - - /** Timestamp when the type was created */ - created_at: string; -} - -/** - * Data required to insert a new notifier type - */ -export type NotifierTypeInsert = Omit; - -/** - * Type for updating an existing notifier type - */ -export type NotifierTypeUpdate = Partial>; diff --git a/src/lib/models/Report.ts b/src/lib/models/Report.ts deleted file mode 100644 index 36c09e77..00000000 --- a/src/lib/models/Report.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Represents a report entity from the database - */ -export type Report = Database['public']['Tables']['reports']['Row']; - -/** - * Type for creating a new report - */ -export type ReportInsert = Database['public']['Tables']['reports']['Insert']; - -/** - * Type for updating an existing report - */ -export type ReportUpdate = Database['public']['Tables']['reports']['Update']; - -/** - * Represents a report alert point entity from the database - */ -export type ReportAlertPoint = Database['public']['Tables']['report_alert_points']['Row']; - -/** - * Type for creating a new report alert point - */ -export type ReportAlertPointInsert = Database['public']['Tables']['report_alert_points']['Insert']; - -/** - * Type for updating an existing report alert point - */ -export type ReportAlertPointUpdate = Database['public']['Tables']['report_alert_points']['Update']; - -/** - * Represents a report recipient entity from the database - */ -export type ReportRecipient = Database['public']['Tables']['report_recipients']['Row']; - -/** - * Type for creating a new report recipient - */ -export type ReportRecipientInsert = Database['public']['Tables']['report_recipients']['Insert']; - -/** - * Type for updating an existing report recipient - */ -export type ReportRecipientUpdate = Database['public']['Tables']['report_recipients']['Update']; - -/** - * Represents a report user schedule entity from the database - */ -export type ReportUserSchedule = Database['public']['Tables']['report_user_schedule']['Row']; - -/** - * Type for creating a new report user schedule - */ -export type ReportUserScheduleInsert = - Database['public']['Tables']['report_user_schedule']['Insert']; - -/** - * Type for updating an existing report user schedule - */ -export type ReportUserScheduleUpdate = - Database['public']['Tables']['report_user_schedule']['Update']; - -/** - * Type for communication method entity - */ -export type CommunicationMethod = Database['public']['Tables']['communication_methods']['Row']; - -/** - * Extended report type with related data - */ -export interface ReportWithDetails extends Report { - alert_points?: ReportAlertPoint[]; - recipients?: ReportRecipient[]; - schedules?: ReportUserSchedule[]; -} - -/** - * Report with recipient and communication method details - */ -export interface ReportWithRecipients extends Report { - recipients?: (ReportRecipient & { - communication_method_details?: CommunicationMethod; - })[]; -} diff --git a/src/lib/models/ReportTemplate.ts b/src/lib/models/ReportTemplate.ts deleted file mode 100644 index f2346e1e..00000000 --- a/src/lib/models/ReportTemplate.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Database } from '../../../database.types'; - -export type ReportTemplate = Database['public']['Tables']['reports_templates']['Row']; -export type ReportTemplateInsert = Database['public']['Tables']['reports_templates']['Insert']; -export type ReportTemplateUpdate = Database['public']['Tables']['reports_templates']['Update']; diff --git a/src/lib/models/Rule.ts b/src/lib/models/Rule.ts deleted file mode 100644 index 0a5446aa..00000000 --- a/src/lib/models/Rule.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Database } from '../../../database.types'; - -export type Rule = Database['public']['Tables']['cw_rules']['Row']; -export type RuleInsert = Database['public']['Tables']['cw_rules']['Insert']; -export type RuleUpdate = Database['public']['Tables']['cw_rules']['Update']; - -export type RuleCriteria = Database['public']['Tables']['cw_rule_criteria']['Row']; -export type RuleCriteriaInsert = Database['public']['Tables']['cw_rule_criteria']['Insert']; -export type RuleCriteriaUpdate = Database['public']['Tables']['cw_rule_criteria']['Update']; - -export type RuleWithCriteria = Rule & { - criteria: RuleCriteria[]; -}; diff --git a/src/lib/models/SoilData.ts b/src/lib/models/SoilData.ts deleted file mode 100644 index b0980976..00000000 --- a/src/lib/models/SoilData.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * SoilData model representing soil sensor data from the cw_soil_data table - */ -export interface SoilData { - /** - * The device EUI that recorded this soil data - */ - dev_eui: string; - - /** - * Soil temperature in Celsius - */ - temperature_c?: number | null; - - /** - * Soil moisture percentage - */ - moisture?: number | null; - - /** - * Soil electrical conductivity - */ - ec?: number | null; - - /** - * Soil pH level - */ - ph?: number | null; - - /** - * Timestamp when the data was recorded - */ - created_at: string; -} - -/** - * Data required to insert new soil data - */ -export type SoilDataInsert = Omit; - -/** - * Type for updating existing soil data - */ -export type SoilDataUpdate = Database['public']['Tables']['cw_soil_data']['Update']; \ No newline at end of file diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts deleted file mode 100644 index 9abe6d51..00000000 --- a/src/lib/models/User.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Database } from '../../../database.types'; - -/** - * Represents a user profile from the database - */ -export type User = Database['public']['Tables']['profiles']['Row']; - -/** - * Data required to insert a new user profile - */ -export type UserInsert = Database['public']['Tables']['profiles']['Insert']; - -/** - * Fields allowed for updating a user profile - */ -export type UserUpdate = Database['public']['Tables']['profiles']['Update']; diff --git a/src/lib/onesignalPublic.ts b/src/lib/onesignalPublic.ts deleted file mode 100644 index 5482bf96..00000000 --- a/src/lib/onesignalPublic.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { env as publicEnv } from '$env/dynamic/public'; - -// Public-only config for client web push setup. -export const ONE_SIGNAL_PUBLIC_CONFIG = { - appId: publicEnv.PUBLIC_ONESIGNAL_APP_ID, - safari_web_id: publicEnv.PUBLIC_ONESIGNAL_SAFARI_WEB_ID -}; diff --git a/src/lib/pdf/fonts.ts b/src/lib/pdf/fonts.ts deleted file mode 100644 index ea1d0521..00000000 --- a/src/lib/pdf/fonts.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Utility for loading fonts for PDF generation - */ - -// We'll use this function to load the font from the static folder -export async function loadNotoSansJP() { - try { - // In a browser environment, fetch the font file - const fontResponse = await fetch('/fonts/NotoSansJP-Regular.ttf'); - if (!fontResponse.ok) { - throw new Error(`Failed to load font: ${fontResponse.statusText}`); - } - - // Convert the font file to an ArrayBuffer - const fontArrayBuffer = await fontResponse.arrayBuffer(); - - // Convert ArrayBuffer to base64 string - const fontBase64 = arrayBufferToBase64(fontArrayBuffer); - - return fontBase64; - } catch (error) { - console.error('Error loading font:', error); - throw error; - } -} - -// Helper function to convert ArrayBuffer to base64 -function arrayBufferToBase64(buffer: ArrayBuffer): string { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - - return btoa(binary); -} diff --git a/src/lib/pdf/index.ts b/src/lib/pdf/index.ts deleted file mode 100644 index af7b518d..00000000 --- a/src/lib/pdf/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface TableCell { - label: string; - shortLabel?: string; - value?: number | string | Date; - color?: string; - bgColor?: string; - fontSize?: number; - width?: number; -} - -export interface TableRow { - header: TableCell; - cells: TableCell[]; -} diff --git a/src/lib/pdf/parseDeviceInstant.ts b/src/lib/pdf/parseDeviceInstant.ts deleted file mode 100644 index 30bd8198..00000000 --- a/src/lib/pdf/parseDeviceInstant.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DateTime } from 'luxon'; - -const tzOffsetPattern = /([zZ]|[+\-]\d{2}:?\d{2})$/; - -export const timestampHasExplicitOffset = (value: string): boolean => - tzOffsetPattern.test(value.trim()); - -/** - * Normalize device timestamps into the requested timezone. Strings without an - * explicit offset are assumed to be stored in UTC (Postgres default) and are - * therefore converted from UTC into the caller's timezone so that report data - * always represents the user's day (JST, etc.) regardless of where the server - * runs. - */ -export function parseDeviceInstant(input: string | Date, tz: string): DateTime { - if (input instanceof Date) { - return DateTime.fromJSDate(input, { zone: 'utc' }).setZone(tz); - } - - if (typeof input !== 'string') { - return DateTime.invalid('Unsupported timestamp type'); - } - - const value = input.trim(); - if (!value) { - return DateTime.invalid('Empty timestamp'); - } - - if (timestampHasExplicitOffset(value)) { - let dt = DateTime.fromISO(value, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(value, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromRFC2822(value, { setZone: true }); - return dt.setZone(tz); - } - - let dt = DateTime.fromISO(value, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromRFC2822(value, { zone: 'utc' }); - return dt.setZone(tz); -} diff --git a/src/lib/pdf/pdfDataTable.ts b/src/lib/pdf/pdfDataTable.ts deleted file mode 100644 index d34f52c3..00000000 --- a/src/lib/pdf/pdfDataTable.ts +++ /dev/null @@ -1,419 +0,0 @@ -import PDFDocument from 'pdfkit'; -import { DateTime } from 'luxon'; -import type { TableRow } from '.'; - -interface TableConfig { - caption?: string; - columnsPerPage: number; - rowsPerColumn: number; - cellWidth: number; - cellHeight: number; - columnMargin: number; - fontSize: number; - headerFontSize: number; - headerHeight: number; - takeEvery: number; // keep every Nth row (always keep rows with alert bgColor) - timezone?: string; // e.g., 'Asia/Tokyo' -} - -const DEFAULT_CONFIG: TableConfig = { - caption: '', - columnsPerPage: 4, - rowsPerColumn: 25, - cellWidth: 45, - cellHeight: 20, - columnMargin: 3, - fontSize: 12, - headerFontSize: 10, - headerHeight: 20, - takeEvery: 3, - timezone: 'utc' -}; - -const MIN_COLUMN_SCALE = 0.9; - -export const rowHasAlert = (row: TableRow) => - [row.header, ...row.cells].some((cell) => cell.bgColor && cell.bgColor !== '#ffffff'); - -export function sampleDataRowsForTable( - dataRows: TableRow[], - takeEvery: number = DEFAULT_CONFIG.takeEvery -): TableRow[] { - const samplingInterval = Math.max(1, takeEvery || 1); - if (samplingInterval <= 1) return dataRows; - return dataRows.filter((row, idx) => idx % samplingInterval === 0 || rowHasAlert(row)); -} - -function getRowTimestamp(row: TableRow): number | null { - const value = row?.header?.value; - if (value instanceof Date) return value.getTime(); - if (typeof value === 'number') return value; - if (typeof value === 'string' && value.length) { - const parsed = Date.parse(value); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - if (row?.header?.label) { - const parsed = Date.parse(row.header.label); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - return null; -} - -export function sampleDataRowsByInterval( - dataRows: TableRow[], - intervalMinutes: number -): TableRow[] { - const intervalMs = intervalMinutes > 0 ? intervalMinutes * 60 * 1000 : 0; - if (!intervalMs) return dataRows; - const sampled: TableRow[] = []; - let lastKeptTimestamp: number | null = null; - - for (const row of dataRows) { - const timestamp = getRowTimestamp(row); - const hasAlert = rowHasAlert(row); - const shouldInclude = - hasAlert || - !sampled.length || - timestamp === null || - lastKeptTimestamp === null || - timestamp - lastKeptTimestamp >= intervalMs; - - if (shouldInclude) { - sampled.push(row); - if (timestamp !== null) { - lastKeptTimestamp = timestamp; - } - } - } - - return sampled; -} - -/** - * Parse the header.value (epoch ms | ISO | SQL) into a DateTime in the desired zone. - */ -function parseHeaderInstant(val: unknown, zone?: string): DateTime | null { - const z = zone || 'utc'; - try { - if (typeof val === 'number') { - // epoch millis → absolute instant → render in zone - return DateTime.fromMillis(val, { zone: 'utc' }).setZone(z); - } - if (typeof val === 'string') { - // prefer ISO with setZone to respect embedded offsets (+09:00), then coerce to z - const iso = DateTime.fromISO(val, { setZone: true }); - if (iso.isValid) return iso.setZone(z); - const sql = DateTime.fromSQL(val, { setZone: true }); - if (sql.isValid) return sql.setZone(z); - } - } catch { - // fall through - } - return null; -} - -/** - * Creates a lean PDF data table that displays data from top to bottom, left to right. - */ -export function createPDFDataTable({ - doc, - dataHeader, - dataRows, - config = {} -}: { - doc: InstanceType; - dataHeader: TableRow; - dataRows: TableRow[]; - config?: Partial; -}): void { - const conf: TableConfig = { ...DEFAULT_CONFIG, ...config }; - - const workingRows = sampleDataRowsForTable(dataRows, conf.takeEvery); - - const { caption, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } = conf; - - const { - top: marginTop, - right: marginRight, - bottom: marginBottom, - left: marginLeft - } = doc.page.margins; - - // Page geometry - const pageHeight = doc.page.height; - - const pageWidth = doc.page.width; - const availableWidth = pageWidth - marginLeft - marginRight; - const columns = [dataHeader.header, ...dataHeader.cells]; - const columnCount = columns.length; - const uniformWidth = Math.max(1, cellWidth); - const previousFontSize = (doc as unknown as { _fontSize?: number })._fontSize ?? conf.fontSize; - const bodyFontSize = Math.max(6, (dataHeader.header.fontSize ?? conf.fontSize) - 1); - const measurementFontSize = Math.max(6, bodyFontSize); - doc.fontSize(measurementFontSize); - const headerLabelWidth = doc.widthOfString(String(dataHeader.header.label ?? '')); - let widestTimeLabel = headerLabelWidth; - for (const row of workingRows) { - const dt = parseHeaderInstant( - (row.header as any).value ?? (row.header as any).label, - conf.timezone - ); - const labelCandidate = dt ? dt.toFormat('M/d H:mm') : String((row.header as any).label ?? ''); - widestTimeLabel = Math.max(widestTimeLabel, doc.widthOfString(labelCandidate)); - } - const firstColumnBaseWidth = Math.max(uniformWidth, widestTimeLabel + 8); - doc.fontSize(previousFontSize); - const baseColumnWidth = firstColumnBaseWidth + (columnCount - 1) * uniformWidth; - - const computeColumnLayout = (rowsRemaining: number, rowsPerColumn: number) => { - const maxNeededColumns = Math.max(1, Math.ceil(rowsRemaining / rowsPerColumn)); - const maxCandidate = Math.max(1, Math.min(columnsPerPage, maxNeededColumns)); - let chosenCount = 1; - let chosenScale = 1; - let effectiveWidth = baseColumnWidth; - - for (let candidate = maxCandidate; candidate >= 1; candidate--) { - const totalWidthNeeded = baseColumnWidth * candidate + columnMargin * (candidate - 1); - if (totalWidthNeeded <= availableWidth) { - chosenCount = candidate; - chosenScale = 1; - effectiveWidth = baseColumnWidth; - break; - } - - const usableWidth = availableWidth - columnMargin * (candidate - 1); - if (usableWidth <= 0) continue; - const scale = usableWidth / (baseColumnWidth * candidate); - if (candidate === 1 || scale >= MIN_COLUMN_SCALE) { - chosenCount = candidate; - chosenScale = Math.min(1, scale); - effectiveWidth = baseColumnWidth * chosenScale; - break; - } - } - - const totalWidthUsed = - chosenCount * effectiveWidth + columnMargin * Math.max(0, chosenCount - 1); - const offsetX = marginLeft + Math.max(0, (availableWidth - totalWidthUsed) / 2); - - return { - columnsThisPage: chosenCount, - columnScale: chosenScale, - effectiveColumnWidth: effectiveWidth, - offsetX - }; - }; - - if (caption) { - doc.fillColor('black').fontSize(12).text(caption, marginLeft, doc.y); - doc.moveDown(0.5); - } - - let dataIndex = 0; - - while (dataIndex < workingRows.length) { - const pageTop = doc.y; - let availableHeightForPage = pageHeight - marginBottom - pageTop - headerHeight; - - if (availableHeightForPage < cellHeight) { - doc.addPage(); - doc.y = marginTop; - continue; - } - - const actualRowsPerColumn = Math.max(1, Math.floor(availableHeightForPage / cellHeight)); - const rowsRemaining = workingRows.length - dataIndex; - const { columnsThisPage, columnScale, effectiveColumnWidth, offsetX } = computeColumnLayout( - rowsRemaining, - actualRowsPerColumn - ); - let tallestColumnHeight = headerHeight; - const maxColumnsForData = Math.max( - 1, - Math.min(columnsThisPage, Math.ceil(rowsRemaining / actualRowsPerColumn)) - ); - - for (let col = 0; col < maxColumnsForData && dataIndex < workingRows.length; col++) { - const startX = offsetX + col * (effectiveColumnWidth + columnMargin); - const endIndex = Math.min(dataIndex + actualRowsPerColumn, workingRows.length); - const columnRows = workingRows.slice(dataIndex, endIndex); - - if (columnRows.length === 0) { - break; - } - - drawColumn({ - doc, - dataHeader, - dataRows: columnRows, - columnScale, - startX, - startY: pageTop, - config: conf, - firstColumnBaseWidth - }); - - const columnHeight = headerHeight + columnRows.length * cellHeight; - tallestColumnHeight = Math.max(tallestColumnHeight, columnHeight); - dataIndex = endIndex; - } - - doc.y = pageTop + tallestColumnHeight + columnMargin; - - if (dataIndex < workingRows.length) { - doc.addPage(); - doc.y = marginTop; - } - } -} - -function drawColumn({ - doc, - dataHeader, - dataRows, - columnScale, - startX, - startY, - config, - firstColumnBaseWidth -}: { - doc: InstanceType; - dataHeader: TableRow; - dataRows: TableRow[]; - columnScale: number; - startX: number; - startY: number; - config: TableConfig; - firstColumnBaseWidth: number; -}): void { - let currentY = startY; - - const borderColor = '#ccc'; - const { - fontSize: defaultFontSize, - headerFontSize, - headerHeight, - cellHeight, - timezone, - cellWidth: configCellWidth - } = config; - const uniformWidth = Math.max(1, configCellWidth); - const scaledColumns = [dataHeader.header, ...dataHeader.cells].map((col, index) => ({ - ...col, - effectiveWidth: - columnScale * (index === 0 ? Math.max(uniformWidth, firstColumnBaseWidth) : uniformWidth) - })); - const scaledColumnWidth = scaledColumns.reduce((total, col) => total + col.effectiveWidth, 0); - - // Header background - doc.fillColor('#e8e8e8').rect(startX, currentY, scaledColumnWidth, config.headerHeight).fill(); - doc - .strokeColor(borderColor) - .rect(startX, currentY, scaledColumnWidth, config.headerHeight) - .stroke(); - - const getCellX = (index: number): number => - startX + scaledColumns.slice(0, index).reduce((total, col) => total + col.effectiveWidth, 0); - - // Column labels - if (dataRows.length > 0) { - scaledColumns.forEach(({ label, effectiveWidth, fontSize }, index) => { - const headerLabelFontSize = fontSize ?? headerFontSize ?? defaultFontSize; - doc.save(); - doc.rect(getCellX(index), currentY, effectiveWidth, headerHeight).clip(); - doc - .fillColor('#000') - .fontSize(headerLabelFontSize) - .text(label, getCellX(index) + 2, currentY + 2, { - width: Math.max(1, effectiveWidth - 4), - align: 'center', - lineBreak: false - }); - doc.restore(); - }); - } - - currentY += headerHeight; - - // Data rows - dataRows.forEach(({ header, cells }, rowIndex) => { - const isEvenRow = rowIndex % 2 === 0; - - // Row background striping - doc - .fillColor(isEvenRow ? '#ffffff' : '#f9f9f9') - .rect(startX, currentY, scaledColumnWidth, cellHeight) - .fill(); - - // Row border - doc.strokeColor(borderColor).rect(startX, currentY, scaledColumnWidth, cellHeight).stroke(); - - // ——— Compute first-column timestamp label in the requested timezone ——— - const thisDt = parseHeaderInstant((header as any).value ?? (header as any).label, timezone); - const prevDt = - rowIndex > 0 - ? parseHeaderInstant( - (dataRows[rowIndex - 1].header as any).value ?? - (dataRows[rowIndex - 1].header as any).label, - timezone - ) - : null; - - // Fallback: if parsing fails, use provided labels as-is - const thisDay = thisDt - ? thisDt.toFormat('M/d') - : ((header as any).label?.split(' ')?.[0] ?? ''); - const thisFull = thisDt ? thisDt.toFormat('M/d H:mm') : ((header as any).label ?? ''); - const thisTime = thisDt - ? thisDt.toFormat('H:mm') - : ((header as any).shortLabel ?? (header as any).label ?? ''); - - let computedHeaderLabel = thisFull; - if (rowIndex > 0) { - const prevDay = prevDt - ? prevDt.toFormat('M/d') - : ((dataRows[rowIndex - 1].header as any).label?.split(' ')?.[0] ?? ''); - if (prevDay === thisDay) computedHeaderLabel = thisTime; // same local day → short time - } - // ———————————————————————————————————————————————————————————————— - - // Render cells - [header, ...cells].forEach(({ label, bgColor }, cellIndex) => { - const cellX = getCellX(cellIndex); - const { effectiveWidth, fontSize = defaultFontSize } = scaledColumns[cellIndex]; - - // Alert background if provided - if (bgColor && bgColor !== '#ffffff') { - doc.fillColor(bgColor).rect(cellX, currentY, effectiveWidth, cellHeight).fill(); - } - - // Cell border - doc.strokeColor(borderColor).rect(cellX, currentY, effectiveWidth, cellHeight).stroke(); - - // Use computed time label for the first column; other columns use provided labels - const cellLabel = cellIndex === 0 ? computedHeaderLabel : (label ?? ''); - - doc.save(); - doc.rect(cellX, currentY, effectiveWidth, cellHeight).clip(); - const isFirstColumn = cellIndex === 0; - const isLastColumn = cellIndex === scaledColumns.length - 1; - const horizontalPadding = isFirstColumn ? 4 : isLastColumn ? 4 : 2; - const textAlign = isFirstColumn ? 'left' : isLastColumn ? 'right' : 'right'; - doc - .fillColor('#000') - .fontSize(Math.max(6, fontSize - 1)) - .text(cellLabel, cellX + horizontalPadding, currentY + 2, { - width: Math.max(1, effectiveWidth - horizontalPadding * 2), - align: textAlign, - lineBreak: false - }); - doc.restore(); - }); - - currentY += cellHeight; - }); -} diff --git a/src/lib/pdf/pdfFooterPageNumber.ts b/src/lib/pdf/pdfFooterPageNumber.ts deleted file mode 100644 index a78b74f5..00000000 --- a/src/lib/pdf/pdfFooterPageNumber.ts +++ /dev/null @@ -1,34 +0,0 @@ -import PDFDocument from 'pdfkit'; - -const footerFontSize = 7; -const footerMargin = 20; - -export function addFooterPageNumber( - doc: InstanceType, - primaryText: string -): void { - // 3) now stamp page numbers on every page - const range = doc.bufferedPageRange(); // { start: 0, count: N } - const total = range.count; - const options = { - width: doc.page.width - 20 - 20, - height: footerFontSize + 2, - lineBreak: false - }; - - for (let i = 0; i < total; i++) { - doc.switchToPage(i); - - const x = 20; - const y = doc.page.height - footerMargin - 5; - - doc - .fontSize(footerFontSize) - .fillColor('black') - .text(primaryText, x, y, { ...options, align: 'left' }) - .text(`Page ${i + 1} / ${total}`, x, y, { ...options, align: 'right' }); - } - - // 4) go back to the last page so that any post‐footer work (if any) ends up there - doc.switchToPage(total - 1); -} diff --git a/src/lib/pdf/pdfLineChart.ts b/src/lib/pdf/pdfLineChart.ts deleted file mode 100644 index 4445b8f7..00000000 --- a/src/lib/pdf/pdfLineChart.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { ReportAlertPoint } from '$lib/models/Report'; -import { DateTime } from 'luxon'; -import PDFDocument from 'pdfkit'; -import type { TableRow } from '.'; - -interface ChartConfig { - width: number; - height: number; - margin: { - top: number; - right: number; - bottom: number; - left: number; - }; - gridLines: boolean; - showLegend: boolean; - colors: string[]; - fontSize: number; - lineWidth: number; - pointRadius: number; - title?: string; - legendLabel?: string; - xAxisLabel?: string; - yAxisLabel?: string; -} - -const DEFAULT_CHART_CONFIG: ChartConfig = { - width: 500, - height: 300, - margin: { - top: 40, - right: 60, - bottom: 60, - left: 80 - }, - gridLines: true, - showLegend: false, - colors: ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#c2410c'], - fontSize: 6, - lineWidth: 2, - pointRadius: 3, - title: '', - legendLabel: '', - xAxisLabel: '', - yAxisLabel: '' -}; - -/** - * Creates a line chart in a PDFKit document - * @param {object} params - * @param params.doc - PDFKit document instance - * @param params.dataHeader - Array of column definitions - * @param params.dataRows - Array of data rows, each row is an array of values - * @param params.alertData - Alert configuration data (optional, for color coding) - * @param params.config - Chart configuration options - */ -export function createPDFLineChart({ - doc, - dataHeader, - dataRows, - alertPoints, - config = {} -}: { - doc: InstanceType; - dataHeader: TableRow; - dataRows: TableRow[]; - alertPoints: ReportAlertPoint[]; - config?: Partial; -}): void { - const conf = { ...DEFAULT_CHART_CONFIG, ...config }; - const { fontSize } = conf; - - if (!dataRows || !dataRows.length) { - console.warn('No data provided for line chart'); - return; - } - - // Calculate chart area using current Y position - const chartX = conf.margin.left; - const chartY = doc.y; // Use current Y position instead of fixed margin.top - const chartWidth = conf.width - conf.margin.left - conf.margin.right - 100; - const chartHeight = conf.height - conf.margin.top - conf.margin.bottom - 100; - - // Prepare data for plotting - const timestamps = dataRows.map(({ header }) => header.value as Date); - const valueCount = dataRows[0]?.cells?.length ?? 0; - - if (valueCount === 0) { - console.warn('No values found in data for line chart'); - return; - } - - // Get min/max values for scaling - const allValues = dataRows.flatMap((d) => - d.cells.map(({ value }) => value).filter((value) => typeof value === 'number' && !isNaN(value)) - ) as number[]; - - // Handle case where there are no valid numeric values - if (allValues.length === 0) { - console.warn('No valid numeric values found for line chart'); - return; - } - - const minValue = Math.min(...allValues); - const maxValue = Math.max(...allValues); - const valueRange = maxValue - minValue; - - // Handle case where all values are the same (valueRange = 0) - const paddedMin = valueRange === 0 ? minValue - 1 : minValue - valueRange * 0.1; - const paddedMax = valueRange === 0 ? maxValue + 1 : maxValue + valueRange * 0.1; - - // Time range - const minTime = Math.min(...timestamps.map((t) => t.getTime())); - const maxTime = Math.max(...timestamps.map((t) => t.getTime())); - const timeRange = maxTime - minTime; - - // Scaling functions - const xScale = (timestamp: Date) => { - return timeRange === 0 - ? chartX + chartWidth / 2 - : chartX + ((timestamp.getTime() - minTime) / timeRange) * chartWidth; - }; - - const yScale = (value: number) => { - const paddedRange = paddedMax - paddedMin; - return paddedRange === 0 - ? chartY + chartHeight / 2 - : chartY + chartHeight - ((value - paddedMin) / paddedRange) * chartHeight; - }; - - // Draw title - if (conf.title) { - doc - .fontSize(12) - .fillColor('#000') - .text(conf.title, chartX, chartY - 30, { - width: chartWidth, - align: 'center' - }); - } - - // Draw chart background - doc - .rect(chartX, chartY, chartWidth, chartHeight) - .fillColor('#fafafa') - .fill() - .strokeColor('#ddd') - .stroke(); - - // Draw grid lines - if (conf.gridLines) { - doc.strokeColor('#e5e5e5').lineWidth(0.5); - - // Horizontal grid lines - const yTicks = 5; - for (let i = 0; i <= yTicks; i++) { - const y = chartY + (i / yTicks) * chartHeight; - doc - .moveTo(chartX, y) - .lineTo(chartX + chartWidth, y) - .stroke(); - } - - // Vertical grid lines - const xTicks = Math.min(10, dataRows.length); - for (let i = 0; i <= xTicks; i++) { - const x = chartX + (i / xTicks) * chartWidth; - doc - .moveTo(x, chartY) - .lineTo(x, chartY + chartHeight) - .stroke(); - } - } - - // Draw data lines - for (let valueIndex = 0; valueIndex < valueCount; valueIndex++) { - const { color = conf.colors[valueIndex % conf.colors.length] } = dataHeader.cells[valueIndex]; - doc.strokeColor(color).lineWidth(conf.lineWidth); - - let firstPoint = true; - - for (let i = 0; i < dataRows.length; i++) { - const { header, cells } = dataRows[i]; - const value = cells[valueIndex].value; - - if (typeof value === 'number' && !Number.isNaN(value)) { - const x = xScale(header.value as Date); - const y = yScale(value); - - if (firstPoint) { - doc.moveTo(x, y); - firstPoint = false; - } else { - doc.lineTo(x, y); - } - } - } - - doc.stroke(); - - // Draw data points - doc.fillColor(color); - for (let i = 0; i < dataRows.length; i++) { - const { header, cells } = dataRows[i]; - const value = cells[valueIndex].value; - - if (typeof value === 'number' && !Number.isNaN(value)) { - const x = xScale(header.value as Date); - const y = yScale(value); - - doc.circle(x, y, conf.pointRadius).fill(); - } - } - } - - // Draw axes - doc.strokeColor('#000').lineWidth(1); - - // Y-axis - doc - .moveTo(chartX, chartY) - .lineTo(chartX, chartY + chartHeight) - .stroke(); - - // X-axis - doc - .moveTo(chartX, chartY + chartHeight) - .lineTo(chartX + chartWidth, chartY + chartHeight) - .stroke(); - - // Y-axis labels and ticks - doc.fontSize(fontSize).fillColor('#000'); - const yTicks = 5; - for (let i = 0; i <= yTicks; i++) { - const value = paddedMin + (i / yTicks) * (paddedMax - paddedMin); - const y = chartY + chartHeight - (i / yTicks) * chartHeight; - - // Tick mark - doc - .moveTo(chartX - 5, y) - .lineTo(chartX, y) - .stroke(); - - // Label - doc.text(value.toFixed(1), chartX - 50, y - 5, { - width: 40, - align: 'right' - }); - } - - // X-axis labels and ticks - const maxXLabels = 6; - const labelInterval = Math.max(1, Math.floor(dataRows.length / maxXLabels)); - - for (let i = 0; i < dataRows.length; i += labelInterval) { - const timestamp = new Date(dataRows[i].header.value as Date); - const x = xScale(timestamp); - - // Tick mark - doc - .moveTo(x, chartY + chartHeight) - .lineTo(x, chartY + chartHeight + 5) - .stroke(); - - // Label - const dateStr = DateTime.fromJSDate(timestamp).toFormat('MM/dd\nHH:mm'); - doc.text(dateStr, x - 20, chartY + chartHeight + 10, { - width: 40, - align: 'center' - }); - } - - // Axis labels - if (conf.yAxisLabel) { - doc - .save() - .rotate(-90) - .fontSize(fontSize) - .text(conf.yAxisLabel, chartX - 60, chartY + chartHeight / 2, { - align: 'center' - }) - .restore(); - } - - if (conf.xAxisLabel) { - doc - .fontSize(fontSize) - .text(conf.xAxisLabel, chartX + chartWidth / 2 - 20, chartY + chartHeight + 40, { - width: 40, - align: 'center' - }); - } - - // Draw legend - if (conf.showLegend && valueCount > 1) { - const legendX = chartX + chartWidth + 20; - let legendY = chartY; - - doc.fontSize(fontSize).fillColor('#000'); - doc.text(conf.legendLabel ?? 'Legend', legendX, legendY); - legendY += 20; - - for (let i = 0; i < valueCount; i++) { - const { label, color = conf.colors[i % conf.colors.length] } = dataHeader.cells[i]; - - // Color square - doc.fillColor(color).rect(legendX, legendY, 12, 12).fill(); - - // Label - doc.fillColor('#000').text(label, legendX + 18, legendY + 2); - - legendY += 18; - } - } - - // Draw alert thresholds if provided - if (alertPoints.length) { - doc.strokeColor('#ff0000').lineWidth(1).opacity(0.5); - - alertPoints.forEach((alert, index) => { - if (index < valueCount) { - // Draw min threshold - if (alert.min !== null) { - const y = yScale(alert.min); - if (y >= chartY && y <= chartY + chartHeight) { - doc - .moveTo(chartX, y) - .lineTo(chartX + chartWidth, y) - .stroke(); - doc - .fontSize(fontSize) - .fillColor('#ff0000') - .text(`最小: ${alert.min}`, chartX + chartWidth - 60, y - 10); - } - } - - // Draw max threshold - if (alert.max !== null) { - const y = yScale(alert.max); - if (y >= chartY && y <= chartY + chartHeight) { - doc - .moveTo(chartX, y) - .lineTo(chartX + chartWidth, y) - .stroke(); - doc - .fontSize(fontSize) - .fillColor('#ff0000') - .text(`最大: ${alert.max}`, chartX + chartWidth - 60, y + 5); - } - } - } - }); - - doc.opacity(1); // Reset opacity - } - - // Update the Y position to account for chart height and additional spacing - doc.y = chartY + chartHeight + conf.margin.bottom + 20; -} diff --git a/src/lib/pdf/pdfLineChartImage.ts b/src/lib/pdf/pdfLineChartImage.ts deleted file mode 100644 index 3006102d..00000000 --- a/src/lib/pdf/pdfLineChartImage.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { ReportAlertPoint } from '$lib/models/Report'; -import { createCanvas, registerFont } from 'canvas'; -import path from 'path'; -import fs from 'fs'; -import { - CategoryScale, - Chart, - LinearScale, - LineController, - LineElement, - PointElement, - type ChartData, - type ChartOptions -} from 'chart.js'; -import PDFDocument from 'pdfkit'; -import type { TableRow } from '.'; -import { DateTime } from 'luxon'; - -interface ProcessedLabel { - withTime: string[]; - dateOnly: string; - dateKey?: string; -} - -interface ChartConfig { - title?: string; - width: number; - height: number; - options?: ChartOptions; - timezone?: string; - maxUniqueDatesWithTime?: number; - maxLabelsWithTime?: number; -} - -// Attempt to register a bundled font (important for server environments like Vercel where system fonts are minimal) -(() => { - const candidatePaths = [ - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf') - ]; - for (const p of candidatePaths) { - try { - if (fs.existsSync(p)) { - registerFont(p, { family: 'NotoSansJP' }); - Chart.defaults.font.family = 'NotoSansJP'; - break; - } - } catch { - /* ignore */ - } - } -})(); - -Chart.register([CategoryScale, LineController, LineElement, LinearScale, PointElement]); -Chart.defaults.devicePixelRatio = 3; -Chart.defaults.font.size = 20; -Chart.defaults.font.family = Chart.defaults.font.family || 'sans-serif'; - -const FALLBACK_DATASET_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#c2410c']; - -const DEFAULT_CHART_OPTIONS: ChartOptions<'line'> = { - elements: { - line: { - borderWidth: 4, - tension: 0.2 // Smooth line - }, - point: { - radius: 0 // No points on the line - } - } -}; - -/** - * Creates a line chart image using Chart.js and returns it as a buffer. - * @returns A buffer containing the image data for a line chart. - * @see https://www.chartjs.org/docs/latest/getting-started/using-from-node-js.html - */ -const createImage = ({ - data, - options, - width, - height -}: { - data: ChartData<'line', (number | null)[], string | string[]>; - options?: ChartOptions<'line'>; - width: number; - height: number; -}): Buffer => { - const canvas = createCanvas(width, height); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chart = new Chart(canvas as any, { type: 'line', data, options }); - const buffer = canvas.toBuffer(); - - chart.destroy(); - - return buffer; -}; - -/** - * Creates a line chart in a PDFKit document - * @param {object} params - * @param params.doc - PDFKit document instance - * @param params.dataHeader - Array of column definitions - * @param params.dataRows - Array of data rows, each row is an array of values - * @param params.config - Chart configuration options - */ -export const createPDFLineChartImage = ({ - doc, - dataHeader, - dataRows, - config = {} -}: { - doc: InstanceType; - dataHeader: TableRow; - dataRows: TableRow[]; - alertPoints: ReportAlertPoint[]; - config?: Partial; -}): void => { - if (!dataRows?.length) { - console.warn('No data provided for line chart'); - return; - } - - const { left: marinLeft, right: marginRight } = doc.page.margins; - const { - title, - width = 400, - height = 300, - options = DEFAULT_CHART_OPTIONS, - timezone = 'UTC', - maxUniqueDatesWithTime = 8, - maxLabelsWithTime = 24 - } = config; - - const parseRowDateTime = (row: TableRow): DateTime | null => { - const rawValue = row.header.value; - if (rawValue instanceof Date) { - return DateTime.fromJSDate(rawValue, { zone: 'utc' }).setZone(timezone); - } - if (typeof rawValue === 'string' && rawValue.trim().length) { - let dt = DateTime.fromISO(rawValue, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(rawValue, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromRFC2822(rawValue, { setZone: true }); - if (dt.isValid) { - return dt.setZone(timezone); - } - } - if (row.header.label) { - const attemptFormats = ['M/d H:mm', 'M/d HH:mm', 'yyyy-MM-dd HH:mm', 'yyyy-MM-dd']; - for (const format of attemptFormats) { - const dt = DateTime.fromFormat(row.header.label, format, { zone: timezone }); - if (dt.isValid) { - return dt; - } - } - } - return null; - }; - - const uniqueDateKeys = new Set(); - - let lastDateTime: DateTime | null = null; - - let processedLabels: ProcessedLabel[] = dataRows.map((row) => { - const dt = parseRowDateTime(row); - if (dt) { - const dateKey = dt.toISODate() ?? dt.toFormat('yyyy-MM-dd'); - if (dateKey) uniqueDateKeys.add(dateKey); - if (!lastDateTime || dt > lastDateTime) { - lastDateTime = dt; - } - const datePart = dt.toFormat('LLL d'); - const timePart = dt.toFormat('HH:mm'); - return { - withTime: [datePart, timePart], - dateOnly: datePart, - dateKey - }; - } - - const label = row.header.label || row.header.shortLabel || ''; - if (label.includes('\n')) { - const [firstLine, secondLine] = label.split('\n'); - if (firstLine) uniqueDateKeys.add(firstLine); - return { - withTime: secondLine ? [firstLine, secondLine] : [firstLine], - dateOnly: firstLine, - dateKey: firstLine || undefined - }; - } - - if (label.includes(' ')) { - const [firstPart, ...rest] = label.split(' '); - const secondPart = rest.join(' ').trim(); - if (firstPart) uniqueDateKeys.add(firstPart); - return { - withTime: secondPart ? [firstPart, secondPart] : [firstPart], - dateOnly: firstPart, - dateKey: firstPart || undefined - }; - } - - if (label) { - uniqueDateKeys.add(label); - } - - return { - withTime: [label], - dateOnly: label, - dateKey: label || undefined - }; - }); - - let appendedNextDayLabel = false; - - if (lastDateTime !== null) { - const nextDayStart = (lastDateTime as DateTime).plus({ days: 1 }).startOf('day'); - const nextDateKey = nextDayStart.toISODate() ?? nextDayStart.toFormat('yyyy-MM-dd'); - if (nextDateKey) uniqueDateKeys.add(nextDateKey); - const datePart = nextDayStart.toFormat('LLL d'); - const timePart = nextDayStart.toFormat('HH:mm'); - processedLabels = [ - ...processedLabels, - { - withTime: [datePart, timePart], - dateOnly: datePart, - dateKey: nextDateKey - } - ]; - appendedNextDayLabel = true; - } - - const includeTime = - uniqueDateKeys.size <= maxUniqueDatesWithTime && processedLabels.length <= maxLabelsWithTime; - - const chartLabels: (string | string[])[] = processedLabels.map((parts) => - includeTime ? parts.withTime : parts.dateOnly - ); - - const firstIndexByDateKey = new Map(); - processedLabels.forEach((entry, index) => { - const key = entry.dateKey ?? entry.dateOnly ?? String(index); - if (!firstIndexByDateKey.has(key)) { - firstIndexByDateKey.set(key, index); - } - }); - - const maxTicksLimit = includeTime - ? Math.min(chartLabels.length, 12) - : Math.min(uniqueDateKeys.size || chartLabels.length, 12); - - const datasets = dataHeader.cells.map((cell, index) => { - const color = cell.color || FALLBACK_DATASET_COLORS[index % FALLBACK_DATASET_COLORS.length]; - const data = dataRows.map((row) => { - const raw = row.cells[index]?.value; - return typeof raw === 'number' && !Number.isNaN(raw) ? raw : null; - }); - if (appendedNextDayLabel) { - data.push(null); - } - return { - label: cell.label, - data, - borderColor: color, - spanGaps: true - }; - }); - - const chartOptions = { - ...DEFAULT_CHART_OPTIONS, - ...options, - scales: { - ...(options?.scales ?? {}), - x: { - type: 'category', - ...(options?.scales?.x ?? {}), - ticks: { - ...(options?.scales?.x?.ticks ?? {}), - autoSkip: false, - maxTicksLimit, - includeBounds: true, - callback(value: string | number, index: number) { - const parsedIndex = - typeof value === 'number' ? value : Number.parseInt(String(value), 10); - const fallbackIndex = Number.isNaN(parsedIndex) ? index : parsedIndex; - const entry = processedLabels[fallbackIndex] ?? processedLabels[index]; - if (!entry) return ''; - const dateKey = entry.dateKey ?? entry.dateOnly ?? String(fallbackIndex); - const firstIndex = firstIndexByDateKey.get(dateKey) ?? fallbackIndex; - if (fallbackIndex !== firstIndex) { - return ''; - } - return includeTime ? entry.withTime : entry.dateOnly; - } - } - } - } - } as ChartOptions<'line'>; - - const data: ChartData<'line', (number | null)[], string | string[]> = { - labels: chartLabels, - datasets - }; - - const buffer = createImage({ data, options: chartOptions, width, height }); - - doc.x = marinLeft; - doc.image(buffer, { width, height }); - - if (title) { - doc.fontSize(8).text(title, marinLeft, doc.y, { - width: doc.page.width - marinLeft - marginRight, - align: 'center' - }); - } -}; diff --git a/src/lib/pdf/utils.ts b/src/lib/pdf/utils.ts deleted file mode 100644 index 5e3c0659..00000000 --- a/src/lib/pdf/utils.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ReportAlertPoint } from '$lib/models/Report'; - -/** - * Check if a value matches the alert point condition. - */ -export const checkMatch = (_value: number, alertPoint: ReportAlertPoint): boolean => { - const { operator, value, min, max } = alertPoint; - - if (operator === '>') { - return _value > (value ?? 0); - } - - if (operator === '<') { - return _value < (value ?? Infinity); - } - - if (operator === '=') { - return _value === (value ?? 0); - } - - if (operator === 'range') { - return _value >= (min ?? 0) && _value <= (max ?? Infinity); - } - - return false; -}; - -/** - * Get the value for summary statistics. - */ -export const getValue = (valueList: number[], indicator: string): number => { - const valueMap = { - min: Math.min(...valueList), - max: Math.max(...valueList), - avg: valueList.reduce((sum, val) => sum + val, 0) / valueList.length, - stddev: Math.sqrt( - valueList.reduce( - (sum, val) => - sum + Math.pow(val - valueList.reduce((sum, v) => sum + v, 0) / valueList.length, 2), - 0 - ) / valueList.length - ) - } as Record; - - return valueMap[indicator] ?? 0; -}; - -/** - * Evaluates whether a value meets the alert condition - * @param value - The data value to check - * @param alertPoint - The alert point configuration - * @returns boolean indicating if the condition is met - */ -export function evaluateAlertCondition(value: number, alertPoint: ReportAlertPoint): boolean { - if (!alertPoint.operator) { - return false; - } - - switch (alertPoint.operator) { - case '>': - return alertPoint.min !== null && value > alertPoint.min; - case '>=': - return alertPoint.min !== null && value >= alertPoint.min; - case '<': - return alertPoint.max !== null && value < alertPoint.max; - case '<=': - return alertPoint.max !== null && value <= alertPoint.max; - case '==': - case '=': - return ( - (alertPoint.min !== null && value === alertPoint.min) || - (alertPoint.max !== null && value === alertPoint.max) - ); - case '!=': - return ( - alertPoint.min !== null && - value !== alertPoint.min && - alertPoint.max !== null && - value !== alertPoint.max - ); - case 'between': - return ( - alertPoint.min !== null && - alertPoint.max !== null && - value >= alertPoint.min && - value <= alertPoint.max - ); - case 'outside': - return ( - alertPoint.min !== null && - alertPoint.max !== null && - (value < alertPoint.min || value > alertPoint.max) - ); - default: - return false; - } -} diff --git a/src/lib/pwa/manifest.spec.ts b/src/lib/pwa/manifest.spec.ts new file mode 100644 index 00000000..d2419b3d --- /dev/null +++ b/src/lib/pwa/manifest.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { buildManifest } from './manifest'; + +describe('buildManifest', () => { + it('builds a root-scoped manifest with installable metadata', () => { + const manifest = buildManifest('', 'en'); + + expect(manifest.id).toBe('/'); + expect(manifest.scope).toBe('/'); + expect(manifest.start_url).toBe('/'); + expect(manifest.display).toBe('standalone'); + expect(manifest.display_override).toEqual( + expect.arrayContaining(['window-controls-overlay', 'standalone']) + ); + expect(manifest.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + src: '/icons/maskable-icon-512x512.png', + purpose: 'maskable' + }) + ]) + ); + expect(manifest.shortcuts.map((shortcut) => shortcut.name)).toEqual([ + 'Dashboard', + 'Locations', + 'Rules', + 'Reports' + ]); + }); + + it('prefixes routes and assets when served from a base path', () => { + const manifest = buildManifest('/cropwatch'); + + expect(manifest.id).toBe('/cropwatch/'); + expect(manifest.scope).toBe('/cropwatch/'); + expect(manifest.start_url).toBe('/cropwatch/'); + expect(manifest.icons[0]?.src).toBe('/cropwatch/icons/maskable-icon-192x192.png'); + expect(manifest.shortcuts[1]?.url).toBe('/cropwatch/locations'); + }); +}); diff --git a/src/lib/pwa/manifest.ts b/src/lib/pwa/manifest.ts new file mode 100644 index 00000000..c66c9ede --- /dev/null +++ b/src/lib/pwa/manifest.ts @@ -0,0 +1,134 @@ +import { getUiLocale } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; + +type ManifestIcon = { + src: string; + sizes: string; + type: string; + purpose?: string; +}; + +type ManifestShortcut = { + name: string; + short_name?: string; + description?: string; + url: string; + icons?: ManifestIcon[]; +}; + +type WebAppManifest = { + id: string; + name: string; + short_name: string; + description: string; + lang: string; + dir: 'ltr' | 'rtl' | 'auto'; + display: 'standalone'; + display_override: Array<'window-controls-overlay' | 'standalone' | 'minimal-ui' | 'browser'>; + scope: string; + start_url: string; + background_color: string; + theme_color: string; + categories: string[]; + prefer_related_applications: boolean; + icons: ManifestIcon[]; + shortcuts: ManifestShortcut[]; +}; + +function normalizeBasePath(basePath: string): string { + if (!basePath || basePath === '/') { + return ''; + } + + return basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; +} + +function rootPath(basePath: string): string { + const normalizedBasePath = normalizeBasePath(basePath); + return normalizedBasePath ? `${normalizedBasePath}/` : '/'; +} + +function withBase(basePath: string, path: string): string { + if (path === '/') { + return rootPath(basePath); + } + + return `${normalizeBasePath(basePath)}${path}`; +} + +export function buildManifest(basePath = '', locale: 'ja' | 'en' = 'ja'): WebAppManifest { + const appRoot = rootPath(basePath); + const resolvedLocale = getUiLocale(locale); + const shortcutIcon: ManifestIcon = { + src: withBase(basePath, '/icons/maskable-icon-192x192.png'), + sizes: '192x192', + type: 'image/png' + }; + + return { + id: appRoot, + name: m.app_name({}, { locale: resolvedLocale }), + short_name: m.app_name({}, { locale: resolvedLocale }), + description: m.manifest_description({}, { locale: resolvedLocale }), + lang: resolvedLocale === 'en' ? 'en-US' : 'ja-JP', + dir: 'ltr', + display: 'standalone', + display_override: ['window-controls-overlay', 'standalone', 'minimal-ui', 'browser'], + scope: appRoot, + start_url: appRoot, + background_color: '#1f283b', + theme_color: '#1f283b', + categories: ['business', 'productivity', 'utilities'], + prefer_related_applications: false, + icons: [ + { + src: withBase(basePath, '/icons/maskable-icon-192x192.png'), + sizes: '192x192', + type: 'image/png' + }, + { + src: withBase(basePath, '/icons/maskable-icon-512x512.png'), + sizes: '512x512', + type: 'image/png' + }, + { + src: withBase(basePath, '/icons/maskable-icon-192x192.png'), + sizes: '192x192', + type: 'image/png', + purpose: 'maskable' + }, + { + src: withBase(basePath, '/icons/maskable-icon-512x512.png'), + sizes: '512x512', + type: 'image/png', + purpose: 'maskable' + } + ], + shortcuts: [ + { + name: m.manifest_shortcut_dashboard_name({}, { locale: resolvedLocale }), + description: m.manifest_shortcut_dashboard_description({}, { locale: resolvedLocale }), + url: appRoot, + icons: [shortcutIcon] + }, + { + name: m.manifest_shortcut_locations_name({}, { locale: resolvedLocale }), + description: m.manifest_shortcut_locations_description({}, { locale: resolvedLocale }), + url: withBase(basePath, '/locations'), + icons: [shortcutIcon] + }, + { + name: m.manifest_shortcut_rules_name({}, { locale: resolvedLocale }), + description: m.manifest_shortcut_rules_description({}, { locale: resolvedLocale }), + url: withBase(basePath, '/rules'), + icons: [shortcutIcon] + }, + { + name: m.manifest_shortcut_reports_name({}, { locale: resolvedLocale }), + description: m.manifest_shortcut_reports_description({}, { locale: resolvedLocale }), + url: withBase(basePath, '/reports'), + icons: [shortcutIcon] + } + ] + }; +} diff --git a/src/lib/repositories/BaseRepository.ts b/src/lib/repositories/BaseRepository.ts deleted file mode 100644 index 34c78135..00000000 --- a/src/lib/repositories/BaseRepository.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { SupabaseClient, PostgrestError } from '@supabase/supabase-js'; -import type { IRepository } from '../interfaces/IRepository'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Base abstract repository implementing common CRUD operations - * This provides a foundation for concrete repositories - */ -export abstract class BaseRepository implements IRepository { - /** - * The table name in the database - */ - protected abstract tableName: string; - - /** - * The primary key column name - */ - protected abstract primaryKey: string; - - /** - * Human readable entity name for error messages - */ - protected abstract entityName: string; - - /** - * Constructor with SupabaseClient and ErrorHandlingService dependencies - */ - constructor( - protected supabase: SupabaseClient, - protected errorHandler: ErrorHandlingService - ) {} - - /** - * Find an entity by its primary key - * @param id The primary key value - */ - async findById(id: K): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq(this.primaryKey, id) - .single(); - - if (error) { - // If the error is specifically that no rows were found, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError( - error, - `Error finding ${this.entityName} with ID: ${String(id)}` - ); - } - - return data as T; - } catch (err) { - const message = `Error finding ${this.entityName} with ID: ${String(id)}`; - if (isPostgrestError(err)) { - this.errorHandler.handleDatabaseError(err, message); - } else { - this.errorHandler.logError(err as Error); - throw new Error(message); - } - } - } - - /** - * Get all entities - */ - async findAll(): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*'); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding all ${this.entityName} records`); - } - - return (data as T[]) || []; - } - - /** - * Create a new entity - * @param entity The entity to create - */ - async create(entity: I): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(entity) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error creating ${this.entityName}`); - } - - return data as T; - } - - /** - * Update an existing entity - * @param id The primary key value - * @param entity The entity with updated values - */ - async update(id: K, entity: U): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .update(entity) - .eq(this.primaryKey, id) - .select() - .limit(1) - .single(); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error updating ${this.entityName} with ID: ${String(id)}` - ); - } - - if (!data) { - this.errorHandler.handleNotFound(this.entityName, String(id)); - } - - return data as T; - } - - /** - * Delete an entity by its primary key - * @param id The primary key value - */ - async delete(id: K): Promise { - // First check if the entity exists - const existing = await this.findById(id); - if (!existing) { - this.errorHandler.handleNotFound(this.entityName, String(id)); - } - - const { error } = await this.supabase.from(this.tableName).delete().eq(this.primaryKey, id); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error deleting ${this.entityName} with ID: ${String(id)}` - ); - } - - return true; - } - - /** - * Upsert an entity (insert if not exists, update if exists) - * @param entity The entity to upsert - */ - async upsert(entity: I): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .upsert(entity, { onConflict: this.primaryKey }) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error upserting ${this.entityName}`); - } - - return data as T; - } -} - -function isPostgrestError(err: unknown): err is PostgrestError { - return Boolean(err && typeof err === 'object' && 'code' in err && 'message' in err); -} diff --git a/src/lib/repositories/DeviceOwnersRepository.ts b/src/lib/repositories/DeviceOwnersRepository.ts deleted file mode 100644 index d1032b0f..00000000 --- a/src/lib/repositories/DeviceOwnersRepository.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { - DeviceOwner, - DeviceOwnerInsert, - DeviceOwnerUpdate, - DeviceOwnerWithProfile -} from '../models/DeviceOwner'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for device owner data access - */ -export class DeviceOwnersRepository extends BaseRepository { - protected tableName = 'cw_device_owners'; - protected primaryKey = 'id'; - protected entityName = 'DeviceOwner'; - - /** - * Constructor with Supabase client and error handler dependencies - */ - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find all device owners - */ - async findAll(): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*').order('id'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding all device owners'); - } - - return (data as DeviceOwner[]) || []; - } - - /** - * Find device owners by device EUI - * @param devEui The device EUI - */ - async findByDeviceEui(devEui: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('dev_eui', devEui) - .order('id'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device owners by device EUI: ${devEui}` - ); - } - - return (data as DeviceOwner[]) || []; - } - - /** - * Find device owners by user ID - * @param userId The user ID - */ - async findByUserId(userId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('user_id', userId) - .order('id'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device owners by user ID: ${userId}` - ); - } - - return (data as DeviceOwner[]) || []; - } - - /** - * Find device owners with profile information by device EUI - * @param devEui The device EUI - */ - async findWithProfilesByDeviceEui(devEui: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - profiles!user_id( - id, - full_name, - email, - username - ) - ` - ) - .eq('dev_eui', devEui) - .order('permission_level', { ascending: false }); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device owners with profiles by device EUI: ${devEui}` - ); - } - - // Transform the data to match our DeviceOwnerWithProfile interface - const transformedData = - (data?.map((item) => ({ - ...item, - profile: Array.isArray(item.profiles) ? item.profiles[0] : item.profiles - })) as DeviceOwnerWithProfile[]) || []; - - return transformedData; - } - - /** - * Find a specific device owner entry - * @param devEui The device EUI - * @param userId The user ID - */ - async findByDeviceAndUser(devEui: string, userId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('dev_eui', devEui) - .eq('user_id', userId) - .single(); - - if (error) { - // Don't log error for "not found" cases as they might be expected - if (error.code !== 'PGRST116') { - this.errorHandler.handleDatabaseError( - error, - `Error finding device owner by device EUI ${devEui} and user ID ${userId}` - ); - } - return null; - } - - return data as DeviceOwner; - } - - /** - * Check if a user has permission for a device - * @param devEui The device EUI - * @param userId The user ID - * @param minimumPermission The minimum permission level required (optional) - */ - async hasPermission( - devEui: string, - userId: string, - minimumPermission?: number - ): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('permission_level') - .eq('dev_eui', devEui) - .eq('user_id', userId) - .single(); - - if (error || !data) { - return false; - } - - if (minimumPermission !== undefined) { - // Lower numbers = higher permissions (1=Admin, 2=Editor, 3=Viewer) - return data.permission_level <= minimumPermission; - } - - return true; - } - - /** - * Get devices owned by a user - * @param userId The user ID - */ - async getDevicesByUserId(userId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - cw_devices!dev_eui( - dev_eui, - name, - description, - location_id - ) - ` - ) - .eq('user_id', userId) - .order('permission_level', { ascending: false }); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding devices by user ID: ${userId}`); - } - - return (data as DeviceOwner[]) || []; - } - - /** - * Create a new device owner entry - * @param deviceOwner The device owner data to insert - */ - override async create(entity: I): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(entity) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error creating device owner'); - } - - return data as DeviceOwner; - } - - /** - * Update a device owner entry - * @param id The device owner ID - * @param updates The updates to apply - */ - override async update(id: number, entity: U): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .update(entity) - .eq(this.primaryKey, id) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error updating device owner with ID: ${id}`); - } - - return data as DeviceOwner; - } - - /** - * Delete a device owner entry - * @param id The device owner ID - */ - override async delete(id: number): Promise { - const { error } = await this.supabase.from(this.tableName).delete().eq(this.primaryKey, id); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error deleting device owner with ID: ${id}`); - } - - return true; - } - - /** - * Delete device owner entries by device EUI and user ID - * @param devEui The device EUI - * @param userId The user ID - */ - async deleteByDeviceAndUser(devEui: string, userId: string): Promise { - const { error } = await this.supabase - .from(this.tableName) - .delete() - .eq('dev_eui', devEui) - .eq('user_id', userId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error deleting device owner by device EUI ${devEui} and user ID ${userId}` - ); - } - } - - /** - * Update permission level for a device owner - * @param id The device owner ID - * @param permissionLevel The new permission level - */ - async updatePermission(id: number, permissionLevel: number): Promise { - return this.update(id, { permission_level: permissionLevel }); - } -} diff --git a/src/lib/repositories/DeviceRepository.ts b/src/lib/repositories/DeviceRepository.ts deleted file mode 100644 index ba5439bd..00000000 --- a/src/lib/repositories/DeviceRepository.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { SupabaseClient, QueryData } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { Device, DeviceWithType, DeviceLocation } from '../models/Device'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { Database } from '../../../database.types'; - -/** - * Type for device with joined location and device type data - */ -export type DeviceWithJoins = Device & { - cw_device_type: Database['public']['Tables']['cw_device_type']['Row']; - cw_locations: (DeviceLocation & { - cw_location_owners: Database['public']['Tables']['cw_location_owners']['Row'][]; - })[]; -}; - -/** - * Repository for device data access - */ -export class DeviceRepository extends BaseRepository { - protected tableName = 'cw_devices'; - protected primaryKey = 'dev_eui'; - protected entityName = 'Device'; - - /** - * Constructor with Supabase client and error handler dependencies - */ - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Get a device with its type information - * @param devEui The device EUI - */ - async getDeviceWithType(devEui: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - cw_device_type(*), - cw_device_owners(*, user_id), - ip_log(*) - ` - ) - .eq(this.primaryKey, devEui) - .single(); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device with type by EUI: ${devEui}` - ); - } - - return data as DeviceWithType; - } - - /** - * Get devices by location ID - * @param locationId The location ID - */ - async findByLocation(locationId: number): Promise { - const session = await this.supabase.auth.getSession(); - - // Define the query with proper typing - const query = this.supabase - .from(this.tableName) - .select( - ` - *, - cw_device_type(*), - cw_locations(*, cw_location_owners(*)), - ip_log(*) - ` - ) - .eq('location_id', locationId) - .eq('cw_locations.cw_location_owners.user_id', session.data.session?.user.id); - - const { data, error } = await query; - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding devices by location: ${locationId}` - ); - } - - return (data as DeviceWithJoins[]) || []; - } - - /** - * Get all devices for a location without auth filtering - * @param locationId The location ID - */ - async getAllDevicesForLocation(locationId: number): Promise { - // Define the query with proper typing - const query = this.supabase.from(this.tableName).select('*').eq('location_id', locationId); - - const { data, error } = await query; - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding all devices by location: ${locationId}` - ); - } - - return (data as Device[]) || []; - } - - /** - * Get devices by type ID - * @param typeId The device type ID - */ - async findByType(typeId: number): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*').eq('type', typeId); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding devices by type: ${typeId}`); - } - - return (data as Device[]) || []; - } - - /** - * Find a device owner entry - * @param devEui The device EUI - * @param userId The user ID - */ - async findAllDevicesByOwner(userId: string): Promise { - // 1) Owned devices (cw_devices rows) - const { data: owned, error: ownedErr } = await this.supabase - .from('cw_devices') - .select('*') - .eq('user_id', userId); - - if (ownedErr) return null; - - // 2) Shared devices the user has perms for. - // Prefer a JOIN via the relationship so you get cw_devices rows directly. - // Assumes a FK cw_device_owners(dev_eui) -> cw_devices(dev_eui). - const { data: shared, error: sharedErr } = await this.supabase - .from('cw_device_owners') - .select('cw_devices(*)') // returns rows shaped like { cw_devices: Device } - .eq('user_id', userId) - .lte('permission_level', 3); // pl <= 3 as per your policy - - if (sharedErr) return null; - - // Flatten shared to Device[] - const sharedDevices: Device[] = (shared ?? []).map((r: any) => r.cw_devices).filter(Boolean); - - // Merge & dedupe by dev_eui - const byDevEui = new Map(); - for (const d of [...(owned ?? []), ...sharedDevices]) byDevEui.set(d.dev_eui, d); - - return Array.from(byDevEui.values()); - } - - /** - * Find a device owner entry - * @param devEui The device EUI - * @param userId The user ID - */ - async findDeviceOwner(devEui: string, userId: string): Promise<{ id: number | string } | null> { - const { data: ownerData, error: ownerError } = await this.supabase - .from('cw_devices') - .select('user_id') - .eq('dev_eui', devEui) - .eq('user_id', userId) - .maybeSingle(); - - if (ownerError && ownerError.code !== 'PGRST116') { - this.errorHandler.handleDatabaseError( - ownerError, - `Error validating direct ownership for device ${devEui}` - ); - } - - if (ownerData?.user_id === userId) { - return { id: userId }; - } - - const { data, error } = await this.supabase - .from('cw_device_owners') - .select('id') - .eq('dev_eui', devEui) - .eq('user_id', userId) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - return null; - } - - return data ? { id: data.id } : null; - } - - /** - * Add a user to a device with a specified permission level - * @param devEui The device EUI - * @param userId The user ID - * @param permissionLevel The permission level to assign - */ - async addUserToDevice(devEui: string, userId: string, permissionLevel: number): Promise { - const { error } = await this.supabase.from('cw_device_owners').insert({ - dev_eui: devEui, - user_id: userId, - permission_level: permissionLevel - }); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error adding user ${userId} to device ${devEui}` - ); - } - } - - /** - * Update a user's permission for a device - * @param deviceOwnerId The device owner entry ID - * @param permissionLevel The new permission level - */ - async updateDevicePermission(deviceOwnerId: number, permissionLevel: number): Promise { - const { error } = await this.supabase - .from('cw_device_owners') - .update({ permission_level: permissionLevel }) - .eq('id', deviceOwnerId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error updating permission for device owner ${deviceOwnerId}` - ); - } - } - - /** - * Remove a user from a device - * @param devEui The device EUI - * @param userId The user ID to remove - */ - async removeUserFromDevice(devEui: string, userId: string): Promise { - const { error } = await this.supabase - .from('cw_device_owners') - .delete() - .eq('dev_eui', devEui) - .eq('user_id', userId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error removing user ${userId} from device ${devEui}` - ); - } - } -} diff --git a/src/lib/repositories/DeviceTypeRepository.ts b/src/lib/repositories/DeviceTypeRepository.ts deleted file mode 100644 index 475c8714..00000000 --- a/src/lib/repositories/DeviceTypeRepository.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { DeviceType } from '../models/Device'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for device type data access (read-only operations) - */ -export class DeviceTypeRepository extends BaseRepository { - protected tableName = 'cw_device_type'; - protected primaryKey = 'id'; - protected entityName = 'DeviceType'; - - /** - * Constructor with Supabase client and error handler dependencies - */ - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find all device types - */ - async findAll(): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*').order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding all device types'); - } - - return (data as DeviceType[]) || []; - } - - /** - * Find device type by ID - * @param id The device type ID - */ - async findById(id: number): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('id', id) - .single(); - - if (error) { - // For "no rows found" error, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError(error, `Error finding device type with ID: ${id}`); - } - - return data as DeviceType; - } - - /** - * Find active device types - */ - async findActive(): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('isActive', true) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding active device types'); - } - - return (data as DeviceType[]) || []; - } - - /** - * Find device types by manufacturer - * @param manufacturer The manufacturer name - */ - async findByManufacturer(manufacturer: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .ilike('manufacturer', `%${manufacturer}%`) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device types by manufacturer: ${manufacturer}` - ); - } - - return (data as DeviceType[]) || []; - } - - /** - * Find device types by model - * @param model The model name or pattern - */ - async findByModel(model: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .ilike('model', `%${model}%`) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding device types by model: ${model}`); - } - - return (data as DeviceType[]) || []; - } - - /** - * Find device types by data table - * @param dataTable The data table name - */ - async findByDataTable(dataTable: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('data_table_v2', dataTable) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device types by data table: ${dataTable}` - ); - } - - return (data as DeviceType[]) || []; - } - - /** - * Search for device types by name - * @param searchTerm The search term to match against the device type name - */ - async searchByName(searchTerm: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .ilike('name', `%${searchTerm}%`) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error searching device types by name: ${searchTerm}` - ); - } - - return (data as DeviceType[]) || []; - } - - /** - * Get device types with default upload interval within a range - * @param minInterval Minimum upload interval - * @param maxInterval Maximum upload interval - */ - async findByUploadIntervalRange(minInterval: number, maxInterval: number): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .gte('default_upload_interval', minInterval) - .lte('default_upload_interval', maxInterval) - .order('default_upload_interval'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device types by upload interval range: ${minInterval}-${maxInterval}` - ); - } - - return (data as DeviceType[]) || []; - } - - /** - * Find device types by primary data type - * @param dataType Primary data type (e.g., "temperature", "humidity") - */ - async findByPrimaryDataType(dataType: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .ilike('primary_data_notation', `%${dataType}%`) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding device types by primary data type: ${dataType}` - ); - } - - return (data as DeviceType[]) || []; - } -} diff --git a/src/lib/repositories/GatewayRepository.ts b/src/lib/repositories/GatewayRepository.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/repositories/LocationRepository.ts b/src/lib/repositories/LocationRepository.ts deleted file mode 100644 index 31a1468b..00000000 --- a/src/lib/repositories/LocationRepository.ts +++ /dev/null @@ -1,377 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { Location, LocationInsert, LocationUpdate } from '../models/Location'; -import type { LocationUser } from '../models/LocationUser'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for location data access - */ -export class LocationRepository extends BaseRepository { - protected tableName = 'cw_locations'; - protected primaryKey = 'location_id'; - protected entityName = 'Location'; - - /** - * Constructor with Supabase client and error handler dependencies - */ - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find all locations - */ - async findAll(): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*').order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding all locations'); - } - - return (data as Location[]) || []; - } - - /** - * Find locations by owner ID - * @param ownerId The owner ID - */ - async findByOwnerId(ownerId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('owner_id', ownerId) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding locations by owner ID: ${ownerId}` - ); - } - - return (data as Location[]) || []; - } - - /** - * Create a new location - * @param location The location to create - */ - override async create(entity: I): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(entity) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error creating location'); - } - - return data as Location; - } - - /** - * Update an existing location - * @param id The location ID - * @param location The location with updated values - */ - override async update(id: number, entity: U): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .update(entity) - .eq(this.primaryKey, id) - .select() - .single(); - - if (error) { - // For "no rows found" error, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError(error, `Error updating location with ID: ${id}`); - } - - return data as Location; - } - - /** - * Get all users with access to a location - * @param locationId The location ID - */ - async getLocationUsers(locationId: number): Promise { - const { data, error } = await this.supabase - .from('cw_location_owners') - .select( - ` - id, - user_id, - location_id, - permission_level, - is_active, - description, - profile:user_id ( - full_name, - email, - username - ) - ` - ) - .eq('location_id', locationId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error fetching users for location: ${locationId}` - ); - } - - const normalized = (data ?? []).map((row: any) => ({ - ...row, - profile: Array.isArray(row.profile) ? row.profile[0] : row.profile - })); - - return normalized as LocationUser[]; - } - - /** - * Check if a user has sufficient permissions to access a location - * @param locationId The location ID - * @param userId The user ID - * @param requiredLevel The minimum required permission level (defaults to 3 - Viewer) - */ - async checkUserPermission( - locationId: number, - userId: string, - requiredLevel: number = 3 - ): Promise { - const { data: locationOwner, error: LocationOwnerError } = await this.supabase - .from('cw_locations') - .select('owner_id') - .eq('location_id', locationId) - .eq('owner_id', userId) - .single(); - - if (locationOwner?.owner_id) { - return true; // User is the owner, automatically has full permissions - } - - const { data, error } = await this.supabase - .from('cw_location_owners') - .select('permission_level') - .eq('location_id', locationId) - .eq('user_id', userId) - .eq('is_active', true) - .single(); - - if (error) { - return false; - } - - return (data?.permission_level ?? 4) <= requiredLevel; - } - - /** - * Find a user by email - * @param email The user's email - */ - async findUserByEmail(email: string): Promise<{ id: string } | null> { - const { data, error } = await this.supabase - .from('profiles') - .select('id') - .eq('email', email) - .single(); - - if (error) { - return null; - } - - return data; - } - - /** - * Find a location owner entry - * @param locationId The location ID - * @param userId The user ID - */ - async findLocationOwner(locationId: number, userId: string): Promise<{ id: number } | null> { - const { data, error } = await this.supabase - .from('cw_location_owners') - .select('id') - .eq('location_id', locationId) - .eq('user_id', userId) - .single(); - - if (error) { - return null; - } - - return data; - } - - /** - * Get a location owner entry by ID - * @param id The location owner entry ID - */ - async getLocationOwnerById(id: number): Promise { - const { data, error } = await this.supabase - .from('cw_location_owners') - .select('*') - .eq('id', id) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - this.errorHandler.handleDatabaseError( - error, - `Error finding location owner by record id ${id}` - ); - } - - if (!data) { - return null; - } - - return data as LocationUser; - } - - /** - * Get a location owner entry by ID - * @param id The location owner entry ID - */ - async getLocationUserById(userId: string): Promise<{ user_id: string } | null> { - const { data, error } = await this.supabase - .from('cw_location_owners') - .select('user_id') - .eq('user_id', userId) - .single(); - - if (error) { - return null; - } - - return data; - } - - /** - * Add a user to a location with a specified permission level - * @param locationId The location ID - * @param userId The user ID - * @param permissionLevel The permission level to assign - */ - async addUserToLocation( - locationId: number, - userId: string, - permissionLevel: number - ): Promise { - // fetch the owner_id for this location - const { data: loc, error: locError } = await this.supabase - .from('cw_locations') - .select('owner_id') - .eq('location_id', locationId) - .single(); - - if (locError) { - this.errorHandler.handleDatabaseError( - locError, - `Error getting owner for location ${locationId}` - ); - return; - } - - const { error } = await this.supabase.from('cw_location_owners').insert({ - location_id: locationId, - user_id: userId, - permission_level: permissionLevel, - admin_user_id: loc.owner_id, // ensure this matches the location owner - is_active: true - }); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error adding user ${userId} to location ${locationId}` - ); - } - } - - /** - * Update a user's permission for a location - * @param locationOwnerId The location owner entry ID - * @param permissionLevel The new permission level - */ - async updateUserPermission(locationOwnerId: number, permissionLevel: number): Promise { - const { error } = await this.supabase - .from('cw_location_owners') - .update({ permission_level: permissionLevel }) - .eq('id', locationOwnerId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error updating permission for location owner ${locationOwnerId}` - ); - } - } - - /** - * Remove a user from a location - * @param locationOwnerId The location owner entry ID - */ - async removeUserFromLocationByRecordId(userRecordId: number): Promise { - const { error } = await this.supabase - .from('cw_location_owners') - .delete() - .eq('id', userRecordId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error removing user from location (record id: ${userRecordId})` - ); - } - } - - /** - * Remove a user from a location - * @param locationOwnerId The location owner entry ID - */ - async removeUserFromLocationByUserId(userId: string, locationId: number): Promise { - const { error } = await this.supabase - .from('cw_location_owners') - .delete() - .eq('user_id', userId) - .eq('location_id', locationId) - .single(); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error removing user ${userId} from location ${locationId}` - ); - } - } - - /** - * Count the number of other admin users for a location - * @param locationId The location ID - * @param excludeUserId The user ID to exclude from the count - */ - async countOtherAdmins(locationId: number, excludeUserId: string): Promise { - const { data, error } = await this.supabase - .from('cw_location_owners') - .select('id') - .eq('location_id', locationId) - .eq('permission_level', 1) // Admin - .neq('user_id', excludeUserId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error counting other admins for location ${locationId}` - ); - } - - return data?.length ?? 0; - } -} diff --git a/src/lib/repositories/NotifierTypeRepository.ts b/src/lib/repositories/NotifierTypeRepository.ts deleted file mode 100644 index 79fd368a..00000000 --- a/src/lib/repositories/NotifierTypeRepository.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { NotifierType } from '../models/NotifierType'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for notifier type data access - */ -export class NotifierTypeRepository extends BaseRepository { - protected tableName = 'cw_notifier_types'; - protected primaryKey = 'id'; - protected entityName = 'NotifierType'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find all notifier types - */ - async findAll(): Promise { - const { data, error } = await this.supabase.from(this.tableName).select('*').order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding all notifier types'); - } - - return (data as NotifierType[]) || []; - } -} diff --git a/src/lib/repositories/ReportAlertPointRepository.ts b/src/lib/repositories/ReportAlertPointRepository.ts deleted file mode 100644 index 070a09e0..00000000 --- a/src/lib/repositories/ReportAlertPointRepository.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { - ReportAlertPoint, - ReportAlertPointInsert, - ReportAlertPointUpdate -} from '../models/Report'; - -/** - * Repository for report alert point data operations - */ -export class ReportAlertPointRepository extends BaseRepository { - protected tableName = 'report_alert_points'; - protected primaryKey = 'id'; - protected entityName = 'ReportAlertPoint'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find alert points by report ID - * @param reportId Report ID to search for - */ - async findByReportId(reportId: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('report_id', reportId) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding alert points for report: ${reportId}` - ); - throw error; - } - } - - /** - * Create a new alert point - * @param alertPoint Alert point data to insert - */ - async createAlertPoint(alertPoint: ReportAlertPointInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(alertPoint) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error creating alert point'); - throw error; - } - } - - /** - * Update an alert point - * @param id Alert point ID - * @param updates Alert point updates - */ - async updateAlertPoint(id: number, updates: ReportAlertPointUpdate): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error updating alert point: ${id}`); - throw error; - } - } - - /** - * Upsert an alert point - * @param alertPoint Alert point data to upsert - */ - async upsertAlertPoint(alertPoint: ReportAlertPointInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .upsert(alertPoint) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error upserting alert point'); - throw error; - } - } - - /** - * Delete alert points by report ID - * @param reportId Report ID to delete alert points for - */ - async deleteByReportId(reportId: string): Promise { - try { - const { error } = await this.supabase.from(this.tableName).delete().eq('report_id', reportId); - - if (error) { - throw error; - } - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error deleting alert points for report: ${reportId}` - ); - throw error; - } - } -} diff --git a/src/lib/repositories/ReportRecipientRepository.ts b/src/lib/repositories/ReportRecipientRepository.ts deleted file mode 100644 index 5009b367..00000000 --- a/src/lib/repositories/ReportRecipientRepository.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { - ReportRecipient, - ReportRecipientInsert, - ReportRecipientUpdate, - CommunicationMethod -} from '../models/Report'; - -/** - * Repository for report recipient data operations - */ -export class ReportRecipientRepository extends BaseRepository { - protected tableName = 'report_recipients'; - protected primaryKey = 'id'; - protected entityName = 'ReportRecipient'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find recipients by report ID - * @param reportId Report ID to search for - */ - async findByReportId(reportId: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('report_id', reportId) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding recipients for report: ${reportId}` - ); - throw error; - } - } - - /** - * Find recipients with communication method details by report ID - * @param reportId Report ID to search for - */ - async findWithCommunicationMethodByReportId( - reportId: string - ): Promise<(ReportRecipient & { communication_method_details?: CommunicationMethod })[]> { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - communication_methods:communication_method(*) - ` - ) - .eq('report_id', reportId) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding recipients with communication methods for report: ${reportId}` - ); - throw error; - } - } - - /** - * Create a new recipient - * @param recipient Recipient data to insert - */ - async createRecipient(recipient: ReportRecipientInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(recipient) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error creating recipient'); - throw error; - } - } - - /** - * Update a recipient - * @param id Recipient ID - * @param updates Recipient updates - */ - async updateRecipient(id: number, updates: ReportRecipientUpdate): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error updating recipient: ${id}`); - throw error; - } - } - - /** - * Upsert a recipient - * @param recipient Recipient data to upsert - */ - async upsertRecipient(recipient: ReportRecipientInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .upsert(recipient) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error upserting recipient'); - throw error; - } - } - - /** - * Delete recipients by report ID - * @param reportId Report ID to delete recipients for - */ - async deleteByReportId(reportId: string): Promise { - try { - const { error } = await this.supabase.from(this.tableName).delete().eq('report_id', reportId); - - if (error) { - throw error; - } - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error deleting recipients for report: ${reportId}` - ); - throw error; - } - } - - /** - * Delete a specific recipient - * @param id Recipient ID to delete - */ - async deleteRecipient(id: number): Promise { - try { - const { error } = await this.supabase.from(this.tableName).delete().eq('id', id); - - if (error) { - throw error; - } - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error deleting recipient: ${id}`); - throw error; - } - } -} diff --git a/src/lib/repositories/ReportRepository.ts b/src/lib/repositories/ReportRepository.ts deleted file mode 100644 index da05d838..00000000 --- a/src/lib/repositories/ReportRepository.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { Report, ReportWithDetails, ReportWithRecipients } from '../models/Report'; -import { BaseRepository } from './BaseRepository'; - -/** - * Repository for report data operations - */ -export class ReportRepository extends BaseRepository { - protected tableName = 'reports'; - protected primaryKey = 'report_id'; - protected entityName = 'Report'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find reports by device EUI - * @param devEui Device EUI to search for - */ - async findAll(): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error finding reports`); - throw error; - } - } - - /** - * Find reports by device EUI - * @param devEui Device EUI to search for - */ - async findByDeviceEui(devEui: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('dev_eui', devEui) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding reports for device: ${devEui}` - ); - throw error; - } - } - - /** - * Find report by report ID - * @param reportId Report ID to search for - */ - async findByReportId(reportId: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('report_id', reportId) - .single(); - - if (error) { - if (error.code === 'PGRST116') { - return null; // No data found - } - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding report by ID: ${reportId}` - ); - throw error; - } - } - - /** - * Find reports with full details (alert points, recipients, schedules) - * @param devEui Device EUI to search for - */ - async findWithDetailsByDeviceEui(devEui: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - report_alert_points(*), - report_recipients(*), - report_user_schedule(*) - ` - ) - .eq('dev_eui', devEui) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - if (!data || !data.length) { - return []; - } - - // Transform data to match ReportWithDetails structure - return data.map((item) => { - const { - report_alert_points: alert_points, - report_recipients: recipients, - report_user_schedule: schedules, - ...rest - } = item; - - return { ...rest, alert_points, recipients, schedules }; - }); - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding reports with details for device: ${devEui}` - ); - throw error; - } - } - - /** - * Find reports with recipient details - * @param devEui Device EUI to search for - */ - async findWithRecipientsByDeviceEui(devEui: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - report_recipients( - *, - communication_methods:communication_method(*) - ) - ` - ) - .eq('dev_eui', devEui) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding reports with recipients for device: ${devEui}` - ); - throw error; - } - } - - /** - * Delete report and all related data (cascade delete) - * @param reportId Report ID to delete - */ - async deleteByReportId(reportId: string): Promise { - try { - const { error } = await this.supabase.from(this.tableName).delete().eq('report_id', reportId); - - if (error) { - throw error; - } - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error deleting report: ${reportId}`); - throw error; - } - } - - /** - * Count reports for a device - * @param devEui Device EUI to count reports for - */ - async countByDeviceEui(devEui: string): Promise { - try { - const { count, error } = await this.supabase - .from(this.tableName) - .select('*', { count: 'exact', head: true }) - .eq('dev_eui', devEui); - - if (error) { - throw error; - } - - return count || 0; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error counting reports for device: ${devEui}` - ); - throw error; - } - } -} diff --git a/src/lib/repositories/ReportTemplateRepository.ts b/src/lib/repositories/ReportTemplateRepository.ts deleted file mode 100644 index bc01bfe4..00000000 --- a/src/lib/repositories/ReportTemplateRepository.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '../../../database.types'; -import { BaseRepository } from './BaseRepository'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { ReportTemplate } from '../models/ReportTemplate'; - -export class ReportTemplateRepository extends BaseRepository { - protected tableName = 'reports_templates'; - protected primaryKey = 'id'; - protected entityName = 'Report'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - async findByOwner(ownerId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('owner_id', ownerId) - .order('created_at', { ascending: false }); - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error fetching user reports'); - } - return (data as ReportTemplate[]) || []; - } -} diff --git a/src/lib/repositories/ReportUserScheduleRepository.ts b/src/lib/repositories/ReportUserScheduleRepository.ts deleted file mode 100644 index 0d29fc97..00000000 --- a/src/lib/repositories/ReportUserScheduleRepository.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { - ReportUserSchedule, - ReportUserScheduleInsert, - ReportUserScheduleUpdate -} from '../models/Report'; - -/** - * Repository for report user schedule data operations - */ -export class ReportUserScheduleRepository extends BaseRepository { - protected tableName = 'report_user_schedule'; - protected primaryKey = 'id'; - protected entityName = 'ReportUserSchedule'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find schedules by report ID - * @param reportId Report ID to search for - */ - async findByReportId(reportId: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('report_id', reportId) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding schedules for report: ${reportId}` - ); - throw error; - } - } - - /** - * Find schedules by device EUI - * @param devEui Device EUI to search for - */ - async findByDeviceEui(devEui: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('dev_eui', devEui) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding schedules for device: ${devEui}` - ); - throw error; - } - } - - /** - * Find active schedules by user ID - * @param userId User ID to search for - */ - async findActiveByUserId(userId: string): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('user_id', userId) - .eq('is_active', true) - .order('created_at', { ascending: false }); - - if (error) { - throw error; - } - - return data || []; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error finding active schedules for user: ${userId}` - ); - throw error; - } - } - - /** - * Create a new schedule - * @param schedule Schedule data to insert - */ - async createSchedule(schedule: ReportUserScheduleInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(schedule) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error creating schedule'); - throw error; - } - } - - /** - * Update a schedule - * @param id Schedule ID - * @param updates Schedule updates - */ - async updateSchedule(id: number, updates: ReportUserScheduleUpdate): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, `Error updating schedule: ${id}`); - throw error; - } - } - - /** - * Upsert a schedule - * @param schedule Schedule data to upsert - */ - async upsertSchedule(schedule: ReportUserScheduleInsert): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .upsert(schedule) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError(error as any, 'Error upserting schedule'); - throw error; - } - } - - /** - * Delete schedules by report ID - * @param reportId Report ID to delete schedules for - */ - async deleteByReportId(reportId: string): Promise { - try { - const { error } = await this.supabase.from(this.tableName).delete().eq('report_id', reportId); - - if (error) { - throw error; - } - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error deleting schedules for report: ${reportId}` - ); - throw error; - } - } - - /** - * Activate/deactivate a schedule - * @param id Schedule ID - * @param isActive Whether the schedule should be active - */ - async setScheduleActive(id: number, isActive: boolean): Promise { - try { - const { data, error } = await this.supabase - .from(this.tableName) - .update({ is_active: isActive }) - .eq('id', id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - } catch (error) { - this.errorHandler.handleDatabaseError( - error as any, - `Error setting schedule active status: ${id}` - ); - throw error; - } - } -} diff --git a/src/lib/repositories/RuleRepository.ts b/src/lib/repositories/RuleRepository.ts deleted file mode 100644 index 0fc24042..00000000 --- a/src/lib/repositories/RuleRepository.ts +++ /dev/null @@ -1,302 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { - Rule, - RuleInsert, - RuleUpdate, - RuleCriteria, - RuleCriteriaInsert, - RuleWithCriteria, - RuleCriteriaUpdate -} from '../models/Rule'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for rule and rule criteria data access - */ -export class RuleRepository extends BaseRepository { - protected tableName = 'cw_rules'; - protected primaryKey = 'id'; - protected entityName = 'Rule'; - protected criteriaTable = 'cw_rule_criteria'; - - /** - * Constructor with Supabase client and error handler dependencies - */ - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Find rules by device EUI - * @param devEui The device EUI - */ - async findByDevice(devEui: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select(`*, cw_rule_criteria(*)`) - .eq('dev_eui', devEui) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding rules by device EUI: ${devEui}`); - } - - return (data as Rule[]) || []; - } - - /** - * Find rules and rule Criteria by device EUI - * @param devEui The device EUI - */ - async getRulesAndCriteriaByDevice(devEui: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select( - ` - *, - cw_rule_criteria(*) - ` - ) - .eq('dev_eui', devEui) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error finding rules by device EUI: ${devEui}`); - } - - const rules = (data ?? []).map((row: any) => { - const { cw_rule_criteria, ...rule } = row ?? {}; - return { - ...rule, - criteria: cw_rule_criteria ?? [] - }; - }); - - return rules as RuleWithCriteria[]; - } - - /** - * Find rules by profile ID - * @param profileId The profile ID - */ - async findByProfile(profileId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*, cw_rule_triggered(*)') - .eq('profile_id', profileId) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding rules by profile ID: ${profileId}` - ); - } - - return (data as Rule[]) || []; - } - - /** - * Find active rules - */ - async findActive(): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('is_active', true) - .order('name'); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding active rules'); - } - - return (data as Rule[]) || []; - } - - /** - * Find rule by rule group ID - * @param ruleGroupId The rule group ID - */ - async findByRuleGroup(ruleGroupId: string): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('*') - .eq('ruleGroupId', ruleGroupId) - .single(); - - if (error) { - // For "no rows found" error, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError( - error, - `Error finding rule by rule group ID: ${ruleGroupId}` - ); - } - - return data as Rule; - } - - /** - * Find criteria by rule group ID - * @param ruleGroupId The rule group ID - */ - async findCriteriaByGroup(ruleGroupId: string): Promise { - const { data, error } = await this.supabase - .from(this.criteriaTable) - .select('*') - .eq('ruleGroupId', ruleGroupId) - .order('id'); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error finding criteria by rule group ID: ${ruleGroupId}` - ); - } - - return (data as RuleCriteria[]) || []; - } - - /** - * Find rule with its criteria - * @param ruleGroupId The rule group ID - */ - async findRuleWithCriteria(ruleGroupId: string): Promise { - const rule = await this.findByRuleGroup(ruleGroupId); - - if (!rule) { - return null; - } - - const criteria = await this.findCriteriaByGroup(ruleGroupId); - - return { - ...rule, - criteria - }; - } - - /** - * Create a new rule - * @param rule The rule to create - */ - override async create(entity: I): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .insert(entity) - .select('*') - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error creating rule'); - } - - return data as Rule; - } - - /** - * Create a new rule criteria - * @param criteria The rule criteria to create - */ - async createCriteria(criteria: RuleCriteriaInsert): Promise { - const { data, error } = await this.supabase - .from(this.criteriaTable) - .insert(criteria) - .select() - .single(); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error creating rule criteria'); - } - - return data as RuleCriteria; - } - - /** - * Update an existing rule - * @param id The rule ID - * @param rule The rule data to update - */ - override async update(id: number, entity: U): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .update(entity) - .eq(this.primaryKey, id) - .select('*') - .single(); - - if (error) { - // For "no rows found" error, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError(error, `Error updating rule with ID: ${id}`); - } - - return data as Rule; - } - - /** - * Update Criteria of an existing rule - * @param id The rule ID - * @param rule The rule data to update - */ - async updateCriteria(id: number, rule: RuleCriteriaUpdate): Promise { - const { data, error } = await this.supabase - .from(this.criteriaTable) - .update(rule) - .eq(this.primaryKey, id) - .select('*') - .single(); - - if (error) { - // For "no rows found" error, return null - if (error.code === 'PGRST116') { - return null; - } - - this.errorHandler.handleDatabaseError(error, `Error updating rule with ID: ${id}`); - } - - return data as RuleCriteria; - } - - /** - * Delete a rule criteria - * @param id The criteria ID to delete - */ - async deleteCriteria(id: number): Promise { - const { error } = await this.supabase.from(this.criteriaTable).delete().eq('id', id); - - if (error) { - this.errorHandler.handleDatabaseError(error, `Error deleting rule criteria with ID: ${id}`); - } - - return true; - } - - /** - * Delete all criteria for a rule group - * @param ruleGroupId The rule group ID - */ - async deleteCriteriaByGroup(ruleGroupId: string): Promise { - const { error } = await this.supabase - .from(this.criteriaTable) - .delete() - .eq('ruleGroupId', ruleGroupId); - - if (error) { - this.errorHandler.handleDatabaseError( - error, - `Error deleting criteria for rule group: ${ruleGroupId}` - ); - } - - return true; - } -} diff --git a/src/lib/repositories/UserRepository.ts b/src/lib/repositories/UserRepository.ts deleted file mode 100644 index af62e96e..00000000 --- a/src/lib/repositories/UserRepository.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { BaseRepository } from './BaseRepository'; -import type { User } from '../models/User'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -/** - * Repository for user profile data access - */ -export class UserRepository extends BaseRepository { - protected tableName = 'profiles'; - protected primaryKey = 'id'; - protected entityName = 'User'; - - constructor(supabase: SupabaseClient, errorHandler: ErrorHandlingService) { - super(supabase, errorHandler); - } - - /** - * Get all users ordered by full name - */ - async findAll(): Promise { - const { data, error } = await this.supabase - .from(this.tableName) - .select('id, email, full_name, username') - .order('full_name', { ascending: true }); - - if (error) { - this.errorHandler.handleDatabaseError(error, 'Error finding all users'); - } - - return (data as User[]) || []; - } -} diff --git a/src/lib/server/OneSignalService.ts b/src/lib/server/OneSignalService.ts deleted file mode 100644 index d4e2e0a8..00000000 --- a/src/lib/server/OneSignalService.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { env as publicEnv } from '$env/dynamic/public'; -import { env as privateEnv } from '$env/dynamic/private'; - -/** - * Server-side OneSignal push helper (2025 docs: Create message API) - * Uses SvelteKit dynamic env so build won't fail if vars absent at build-time. - * Required at runtime: - * PUBLIC_ONESIGNAL_APP_ID - * PRIVATE_ONESIGNAL_REST_API_KEY - */ -export class OneSignalService { - constructor(private errorHandler: ErrorHandlingService) {} - - private assertEnv() { - if (!publicEnv.PUBLIC_ONESIGNAL_APP_ID) - throw new Error('PUBLIC_ONESIGNAL_APP_ID not configured'); - if (!privateEnv.PRIVATE_ONESIGNAL_REST_API_KEY) - throw new Error('PRIVATE_ONESIGNAL_REST_API_KEY not configured'); - } - - private baseHeaders() { - this.assertEnv(); - return { - 'content-type': 'application/json; charset=utf-8', - authorization: `Key ${privateEnv.PRIVATE_ONESIGNAL_REST_API_KEY}` - }; - } - - async sendPush(options: { - headings?: Record; - contents: Record; - externalUserIds?: string[]; - includedSegments?: string[]; - data?: Record; - iosAttachments?: Record; - bigPicture?: string; - name?: string; - }) { - try { - this.assertEnv(); - const { - headings, - contents, - externalUserIds, - includedSegments, - data, - iosAttachments, - bigPicture, - name - } = options; - if (!contents || Object.keys(contents).length === 0) throw new Error('contents is required'); - if (!externalUserIds?.length && !includedSegments?.length) - throw new Error('Must supply externalUserIds or includedSegments'); - - const body: any = { - app_id: publicEnv.PUBLIC_ONESIGNAL_APP_ID, - target_channel: 'push', - contents, - headings, - data, - name - }; - if (externalUserIds?.length) body.include_external_user_ids = externalUserIds; - if (includedSegments?.length) body.included_segments = includedSegments; - if (iosAttachments) body.ios_attachments = iosAttachments; - if (bigPicture) body.big_picture = bigPicture; - - const res = await fetch('https://api.onesignal.com/notifications', { - method: 'POST', - headers: this.baseHeaders(), - body: JSON.stringify(body) - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`OneSignal error ${res.status}: ${text}`); - } - return await res.json(); - } catch (err) { - this.errorHandler.logError(err as Error); - throw err; - } - } -} diff --git a/src/lib/server/auth/login-action.spec.ts b/src/lib/server/auth/login-action.spec.ts new file mode 100644 index 00000000..0cac1a15 --- /dev/null +++ b/src/lib/server/auth/login-action.spec.ts @@ -0,0 +1,183 @@ +import type { Cookies } from '@sveltejs/kit'; +import { describe, expect, it, vi } from 'vitest'; +import { + handleLoginAction, + type LoginActionDependencies, + type LoginActionEvent +} from './login-action'; + +const messages: LoginActionDependencies['messages'] = { + auth_login_failed: () => 'Unable to sign in right now. Please try again.', + auth_invalid_form_submission: () => 'Invalid form submission.', + auth_security_try_again: () => 'Security verification failed. Please try again.', + auth_invalid_credentials: () => 'Invalid email or password.' +}; + +function createCookiesMock() { + return { + set: vi.fn() + } as unknown as Cookies; +} + +function createRequest( + url: string, + formData: Record, + headers: HeadersInit = {} +): Request { + return new Request(url, { + method: 'POST', + headers, + body: new URLSearchParams(formData) + }); +} + +function createDependencies( + overrides: Partial = {} +): LoginActionDependencies { + return { + publicApiBaseUrl: 'https://api.cropwatch.test', + publicLoginEndpoint: '/auth/login', + secureCookies: true, + verifyRecaptchaToken: vi.fn(async () => ({ success: true })), + messages, + ...overrides + }; +} + +describe('handleLoginAction', () => { + it('sets the jwt cookie and redirects to the sanitized target', async () => { + const cookies = createCookiesMock(); + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + message: 'Login successful.', + result: { + accessToken: 'test-jwt', + expires_at_datetime: new Date(Date.now() + 60_000).toISOString() + } + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + ) as typeof globalThis.fetch; + + const event: LoginActionEvent = { + request: createRequest('https://app.cropwatch.test/auth/login?redirect=%2Fsettings', { + email: 'operator@cropwatch.test', + password: 'CorrectHorseBatteryStaple1!', + recaptchaToken: 'login-token' + }), + cookies, + fetch + }; + + await expect(handleLoginAction(event, createDependencies())).rejects.toMatchObject({ + status: 303, + location: '/settings' + }); + + expect(cookies.set).toHaveBeenCalledWith( + 'jwt', + 'test-jwt', + expect.objectContaining({ + httpOnly: true, + secure: true, + path: '/' + }) + ); + }); + + it('falls back to the root path when the redirect is unsafe', async () => { + const cookies = createCookiesMock(); + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + message: 'Login successful.', + result: { + accessToken: 'test-jwt', + expires_at_datetime: new Date(Date.now() + 60_000).toISOString() + } + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + ) as typeof globalThis.fetch; + + const event: LoginActionEvent = { + request: createRequest( + 'https://app.cropwatch.test/auth/login?redirect=https%3A%2F%2Fevil.example%2Fphish', + { + email: 'operator@cropwatch.test', + password: 'CorrectHorseBatteryStaple1!', + recaptchaToken: 'login-token' + } + ), + cookies, + fetch + }; + + await expect(handleLoginAction(event, createDependencies())).rejects.toMatchObject({ + status: 303, + location: '/' + }); + }); + + it('returns invalid credentials when the upstream login endpoint rejects the user', async () => { + const cookies = createCookiesMock(); + const fetch = vi.fn( + async () => new Response('Unauthorized', { status: 401 }) + ) as typeof globalThis.fetch; + + const event: LoginActionEvent = { + request: createRequest('https://app.cropwatch.test/auth/login', { + email: 'operator@cropwatch.test', + password: 'wrong-password', + recaptchaToken: 'login-token' + }), + cookies, + fetch + }; + + await expect(handleLoginAction(event, createDependencies())).resolves.toMatchObject({ + status: 401, + data: { + message: 'Invalid email or password.' + } + }); + + expect(cookies.set).not.toHaveBeenCalled(); + }); + + it('returns a security failure when reCAPTCHA verification does not pass', async () => { + const cookies = createCookiesMock(); + const fetch = vi.fn() as typeof globalThis.fetch; + const verifyRecaptchaToken = vi.fn(async () => ({ success: false })); + + const event: LoginActionEvent = { + request: createRequest('https://app.cropwatch.test/auth/login', { + email: 'operator@cropwatch.test', + password: 'CorrectHorseBatteryStaple1!', + recaptchaToken: 'login-token' + }), + cookies, + fetch + }; + + await expect( + handleLoginAction(event, createDependencies({ verifyRecaptchaToken })) + ).resolves.toMatchObject({ + status: 400, + data: { + message: 'Security verification failed. Please try again.' + } + }); + + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/auth/login-action.ts b/src/lib/server/auth/login-action.ts new file mode 100644 index 00000000..5f53ccc7 --- /dev/null +++ b/src/lib/server/auth/login-action.ts @@ -0,0 +1,133 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Cookies } from '@sveltejs/kit'; +import type { RecaptchaLogContext, RecaptchaVerificationResult } from '$lib/utils/recaptcha.server'; +import { readRedirectPathFromUrl } from '$lib/utils/auth-redirect'; + +type LoginApiResponse = { + message?: string; + result?: { + accessToken?: string; + expires_at_datetime?: string; + }; +}; + +type LoginMessages = { + auth_login_failed: () => string; + auth_invalid_form_submission: () => string; + auth_security_try_again: () => string; + auth_invalid_credentials: () => string; +}; + +type VerifyRecaptcha = ( + token: string, + expectedAction: string, + minScore?: number, + context?: RecaptchaLogContext +) => Promise; + +export type LoginActionDependencies = { + publicApiBaseUrl: string; + publicLoginEndpoint: string; + secureCookies: boolean; + verifyRecaptchaToken: VerifyRecaptcha; + messages: LoginMessages; +}; + +export type LoginActionEvent = { + request: Request; + cookies: Cookies; + fetch: typeof fetch; +}; + +function readNonEmptyString(value: FormDataEntryValue | null): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export async function handleLoginAction( + { request, cookies, fetch }: LoginActionEvent, + deps: LoginActionDependencies +) { + const { publicApiBaseUrl, publicLoginEndpoint, secureCookies, verifyRecaptchaToken, messages } = + deps; + + if (!publicApiBaseUrl) { + console.error('Missing PUBLIC_API_BASE_URL for login action'); + return fail(500, { message: messages.auth_login_failed() }); + } + + const data = await request.formData(); + const email = readNonEmptyString(data.get('email')); + const password = readNonEmptyString(data.get('password')); + const recaptchaToken = readNonEmptyString(data.get('recaptchaToken')); + + if (!email || !password || !recaptchaToken) { + return fail(400, { message: messages.auth_invalid_form_submission() }); + } + + const recaptchaResult = await verifyRecaptchaToken(recaptchaToken, 'LOGIN', 0.5, { + route: '/auth/login', + flow: 'login', + userAgent: request.headers.get('user-agent') ?? undefined + }); + if (!recaptchaResult.success) { + return fail(400, { message: messages.auth_security_try_again() }); + } + + let response: Response; + try { + response = await fetch(`${publicApiBaseUrl}${publicLoginEndpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + } catch (error) { + console.error('Login request failed:', error); + return fail(502, { message: messages.auth_login_failed() }); + } + + if (!response.ok && (response.status === 401 || response.status === 403)) { + return fail(401, { message: messages.auth_invalid_credentials() }); + } + if (!response.ok) { + return fail(502, { message: messages.auth_login_failed() }); + } + + let result: LoginApiResponse; + try { + result = (await response.json()) as LoginApiResponse; + } catch (error) { + console.error('Login response parse failed:', error); + return fail(502, { message: messages.auth_login_failed() }); + } + + const accessToken = result.result?.accessToken; + const expiresAtRaw = result.result?.expires_at_datetime; + const expiresAt = expiresAtRaw ? new Date(expiresAtRaw) : null; + + if ( + result.message !== 'Login successful.' || + !accessToken || + !expiresAt || + Number.isNaN(expiresAt.getTime()) + ) { + return fail(401, { message: messages.auth_invalid_credentials() }); + } + + const maxAge = Math.floor((expiresAt.getTime() - Date.now()) / 1000); + if (!Number.isFinite(maxAge) || maxAge <= 0) { + return fail(502, { message: messages.auth_login_failed() }); + } + + cookies.set('jwt', accessToken, { + path: '/', + httpOnly: true, + secure: secureCookies, + sameSite: 'lax', + maxAge + }); + + const redirectPath = readRedirectPathFromUrl(new URL(request.url), '/'); + throw redirect(303, redirectPath); +} diff --git a/src/lib/services/AuthService.ts b/src/lib/services/AuthService.ts deleted file mode 100644 index 6024bd12..00000000 --- a/src/lib/services/AuthService.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { SignInWithPasswordCredentials, SupabaseClient } from '@supabase/supabase-js'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { IAuthService } from '../interfaces/IAuthService'; - -/** - * Implementation of AuthService using Supabase - * This service handles user authentication operations - */ -export class AuthService implements IAuthService { - /** - * Constructor with SupabaseClient dependency - */ - constructor( - private supabase: SupabaseClient, - private errorHandler: ErrorHandlingService - ) {} - - /** - * Sign in with email and password using Supabase Auth - * @param email User email - * @param password User password - * @returns Promise with the session or null if authentication failed - */ - async signInWithPassword( - email: string, - password: string - ): Promise<{ user: any; session: any } | null> { - try { - const { data, error } = await this.supabase.auth.signInWithPassword({ - email, - password - }); - - if (error) { - this.errorHandler.logError(error); - return null; - } - - return data; - } catch (error) { - this.errorHandler.logError(error as Error); - return null; - } - } - - /** - * Sign out the current user - * @returns Promise indicating success - */ - async signOut(): Promise { - try { - // Use local scope to ensure we only sign out the current browser session - const { error } = await this.supabase.auth.signOut({ - scope: 'local' // Only sign out the current browser session - }); - - if (error) { - this.errorHandler.logError(error); - } - } catch (error) { - this.errorHandler.logError(error as Error); - } - } - - /** - * Get the current session - * @returns Promise with the session or null if not authenticated - */ - async getSession(): Promise<{ user: any; session: any } | null> { - try { - const { data, error } = await this.supabase.auth.getSession(); - - if (error) { - this.errorHandler.logError(error); - return null; - } - - return data.session ? { user: data.session.user, session: data.session } : null; - } catch (error) { - this.errorHandler.logError(error as Error); - return null; - } - } - - /** - * Register a new user with Supabase Auth and store profile data - * @param userData User registration data including email, password, and profile - * @returns Promise with success status and error message if applicable - */ - async register(userData: { - email: string; - password: string; - firstName: string; - lastName: string; - company: string; - }): Promise<{ - success: boolean; - error?: string; - emailConfirmationRequired?: boolean; - }> { - try { - // Create user account with Supabase Auth - const { data: authData, error: authError } = await this.supabase.auth.signUp({ - email: userData.email, - password: userData.password, - options: { - emailRedirectTo: `${this.getRedirectUrl()}/auth/confirm`, - data: { - first_name: userData.firstName, - last_name: userData.lastName, - company: userData.company, - full_name: `${userData.firstName} ${userData.lastName}` - } - } - }); - - if (authError) { - this.errorHandler.logError(authError); - return { - success: false, - error: authError.message - }; - } - - if (!authData.user) { - return { - success: false, - error: 'User registration failed' - }; - } - - // Check if email confirmation is required - const emailConfirmationRequired = !authData.user.email_confirmed_at; - - return { - success: true, - emailConfirmationRequired, - error: emailConfirmationRequired - ? 'Please check your email to confirm your account before logging in' - : undefined - }; - } catch (error) { - this.errorHandler.logError(error as Error); - return { - success: false, - error: 'An unexpected error occurred during registration' - }; - } - } - - /** - * Sends password reset email to the provided email address - * @param email Email address to send password reset link to - * @returns Promise with success status and error message if applicable - */ - async resetPassword(email: string): Promise<{ success: boolean; error?: string }> { - try { - const { error } = await this.supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${this.getRedirectUrl()}/auth/update-password` - }); - - if (error) { - this.errorHandler.logError(error); - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (err) { - this.errorHandler.logError(err as Error); - return { success: false, error: 'Failed to send password reset email' }; - } - } - - /** - * Updates the user password after reset - * @param newPassword New password to set - * @returns Promise with success status and error message if applicable - */ - async updatePassword(newPassword: string): Promise<{ success: boolean; error?: string }> { - try { - const { error } = await this.supabase.auth.updateUser({ - password: newPassword - }); - - if (error) { - this.errorHandler.logError(error); - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (err) { - this.errorHandler.logError(err as Error); - return { success: false, error: 'Failed to update password' }; - } - } - - /** - * Gets the redirect URL based on the environment - * @returns The base URL for redirects - */ - private getRedirectUrl(): string { - // If running in a browser, use the current origin - if (typeof window !== 'undefined') { - return window.location.origin; - } - - // Otherwise use environment variables or a default - return process.env.PUBLIC_SITE_URL || 'https://cropwatch.io'; - } -} diff --git a/src/lib/services/DashboardService.ts b/src/lib/services/DashboardService.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts deleted file mode 100644 index c2f8a301..00000000 --- a/src/lib/services/DeviceDataService.ts +++ /dev/null @@ -1,811 +0,0 @@ -import type { ReportAlertPoint } from '$lib/models/Report'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import { DateTime } from 'luxon'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import type { IDeviceDataService } from '../interfaces/IDeviceDataService'; -import type { DeviceType } from '../models/Device'; -import type { DeviceDataRecord } from '../models/DeviceDataRecord'; - -export class DeviceDataService implements IDeviceDataService { - private readonly errorHandler: ErrorHandlingService; - - constructor( - private readonly supabase: SupabaseClient, - errorHandler: ErrorHandlingService = new ErrorHandlingService() - ) { - this.errorHandler = errorHandler; - } - - /** - * Get the latest data for a device based on its type, with optimized handling for large tables - * @param devEui The device EUI - */ - public async getLatestDeviceData(devEui: string): Promise { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - const cw_device = await this.getDeviceAndType(devEui); - const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name - - try { - if (tableName === 'cw_traffic2') { - return await this.getLatestTrafficAggregate(devEui); - } - - const { data, error } = await this.supabase - .from(tableName) - .select() - .eq('dev_eui', devEui) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); - - if (error) { - this.errorHandler.logError(error); - throw new Error(`Error fetching columns: ${error.message}`); - } - - return data as DeviceDataRecord; - } catch (error) { - this.errorHandler.logError(error as Error); - if (error instanceof Error && error.message.includes('AbortError')) { - return { - error: 'Data retrieval timed out', - partial: true, - dev_eui: devEui, - created_at: new Date().toISOString(), - note: 'This is a placeholder due to query timeout' - }; - } - throw error; - } - } - - /** - * Get device data within a date range based on device type - * @param devEui The device EUI - * @param deviceType The device type information containing data_table_v2 - * @param startDate The start date in user's timezone - * @param endDate The end date in user's timezone - * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') - */ - public async getDeviceDataByDateRange( - devEui: string, - startDate: Date, - endDate: Date, - timezone: string = 'UTC' - ): Promise { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - if (!startDate || !endDate) { - throw new Error('Start date and end date must be specified'); - } - if (startDate > endDate) { - throw new Error('Start date must be before end date'); - } - - const cw_device = await this.getDeviceAndType(devEui); - const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name - - // Convert user timezone dates to UTC for database queries - const utcStartDate = this.convertUserTimezoneToUTC(startDate, timezone); - const utcEndDate = this.convertUserTimezoneToUTC(endDate, timezone); - - try { - // get the number of uploads between the selected start and end dates - const monthsInRange = Math.floor( - DateTime.fromJSDate(endDate).diff(DateTime.fromJSDate(startDate), 'months').months - ); - if (monthsInRange > 3) { - // If the range is too large, limit the query to the last 3 months - throw new Error('Date range too large'); - } - - const { data, error } = await this.supabase - .from(tableName) - .select('*') - .eq('dev_eui', devEui) - .gte( - tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', - utcStartDate.toISOString() - ) - .lte(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) - .order(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', { - ascending: false - }); - - console.log('Historical RPC params', { - devEui, - start: utcStartDate.toISOString(), - end: utcEndDate.toISOString(), - tableName, - timezone - }); - - if (error) { - this.errorHandler.logError(error); - throw new Error(`Error fetching device data: ${error.message}`); - } - - let records: DeviceDataRecord[]; - if (tableName === 'cw_traffic2') { - records = this.aggregateTrafficCounts(data ?? []); - } else { - records = (data || []) as DeviceDataRecord[]; - } - - // Convert timestamps back to user timezone - return this.convertRecordTimestampsToUserTimezone(records, timezone, tableName); - } catch (error) { - // Handle errors with a generic response - this.errorHandler.logError(error as Error); - - if (error instanceof Error && error.message.includes('AbortError')) { - return [ - { - error: 'Data retrieval timed out', - partial: true, - dev_eui: devEui, - created_at: new Date().toISOString(), - note: 'This is a placeholder due to query timeout' - } - ]; - } - - // Re-throw other errors - throw error; - } - } - - /** - * Get device data within a date range based on device type as a CSV - * @param devEui The device EUI - * @param deviceType The device type information containing data_table_v2 - * @param startDate The start date in user's timezone - * @param endDate The end date in user's timezone - * @param timezone The user's timezone (e.g., 'Asia/Tokyo', 'America/New_York') - */ - public async getDeviceDataByDateRangeAsCSV( - devEui: string, - startDate: Date, - endDate: Date, - timezone: string = 'UTC' - ): Promise { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - if (!startDate || !endDate) { - throw new Error('Start date and end date must be specified'); - } - if (startDate > endDate) { - throw new Error('Start date must be before end date'); - } - - const cw_device = await this.getDeviceAndType(devEui); - const tableName = cw_device.cw_device_type.data_table_v2; // Pull out the table name - - // Convert user timezone dates to UTC for database queries - const utcStartDate = this.convertUserTimezoneToUTC(startDate, timezone); - const utcEndDate = this.convertUserTimezoneToUTC(endDate, timezone); - - try { - // get the number of uploads between the selected start and end dates - const monthsInRange = Math.floor( - DateTime.fromJSDate(endDate).diff(DateTime.fromJSDate(startDate), 'months').months - ); - if (monthsInRange > 3) { - // If the range is too large, limit the query to the last 3 months - throw new Error('Date range too large'); - } - - const { data, error } = await this.supabase - .from(tableName) - .select('*') - .eq('dev_eui', devEui) - .gte( - tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', - utcStartDate.toISOString() - ) - .lte(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', utcEndDate.toISOString()) - .order(tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at', { - ascending: false - }); - - if (error) { - this.errorHandler.logError(error); - throw new Error(`Error fetching CSV data: ${error.message}`); - } - - const rawRecords = - tableName === 'cw_traffic2' - ? this.aggregateTrafficCounts(data ?? []) - : ((data || []) as Record[]); - - const rows = rawRecords.map((r) => ({ ...r })); - const timestampKey = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - - const normalizeToISO = (val: string) => { - let s = val.trim().replace(' ', 'T'); - s = s.replace(/([+-]\d{2})$/, '$1:00'); - s = s.replace(/([+-]\d{2})(\d{2})$/, '$1:$2'); - return s; - }; - - for (const row of rows) { - const raw = row[timestampKey]; - if (!raw) continue; - const isoLike = typeof raw === 'string' ? normalizeToISO(raw) : String(raw); - let dt = DateTime.fromISO(isoLike, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(String(raw), { setZone: true }); - if (dt.isValid) { - // Format for Microsoft Excel compatibility: "yyyy-MM-dd HH:mm:ss" (no T, no timezone) - const excelLocal = dt.setZone(timezone).toFormat('yyyy-LL-dd HH:mm:ss'); - - // For traffic data, update both created_at and traffic_hour to maintain consistency - if (tableName === 'cw_traffic2') { - row.created_at = excelLocal; - // For CSV consistency, also format traffic_hour in the same Excel-friendly format - row.traffic_hour = excelLocal; - } else { - // For other data types, standardize on created_at - row.created_at = excelLocal; - } - } - } - - // Build header order: dev_eui, created_at, then the rest in natural order - const headerSet = new Set(); - for (const row of rows) { - Object.keys(row).forEach((k) => headerSet.add(k)); - } - const headers = [ - 'dev_eui', - 'created_at', - ...Array.from(headerSet).filter((k) => k !== 'dev_eui' && k !== 'created_at') - ]; - - const csv = this.toCSV(rows, headers); - return csv as unknown as DeviceDataRecord[]; - } catch (error) { - // Handle errors with a generic response - this.errorHandler.logError(error as Error); - - if (error instanceof Error && error.message.includes('AbortError')) { - return [ - { - error: 'Data retrieval timed out', - partial: true, - dev_eui: devEui, - created_at: new Date().toISOString(), - note: 'This is a placeholder due to query timeout' - } - ]; - } - - // Re-throw other errors - throw error; - } - } - - /** - * Get the latest data for a device based on its type, with optimized handling for large tables - * @param devEui The device EUI - * @param deviceType The device type information containing data_table_v2 - */ - private async getDeviceAndType( - devEui: string - ): Promise<{ cw_device_type: DeviceType } & Record> { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - const { data: cw_device, error: deviceError } = await this.supabase - .from('cw_devices') - .select('*, cw_device_type(*)') - .eq('dev_eui', devEui) - .single(); - - if (deviceError) { - this.errorHandler.logError(deviceError); - throw new Error(`Error fetching device type: ${deviceError.message}`); - } - - if (!cw_device || !cw_device.cw_device_type.data_table_v2) { - throw new Error('Device type or data table not specified'); - } - - return cw_device; - } - - public async getDeviceDataForReport({ - devEui, - startDate, - endDate, - timezone, - intervalMinutes, - columns, - ops, - mins, - maxs - }: { - devEui: string; - startDate: Date; - endDate: Date; - timezone: string; - intervalMinutes: number; - columns?: string[]; - ops?: string[]; - mins?: number[]; - maxs?: (number | null)[]; - }): Promise { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - if (!startDate || !endDate) { - throw new Error('Start date and end date must be specified'); - } - if (startDate > endDate) { - throw new Error('Start date must be before end date'); - } - - try { - // If no filtering parameters provided, get alert points from the device's reports - let p_columns = !columns || columns.length === 0 ? ['temperature_c', 'humidity'] : columns; - let p_ops = ops || ['>', 'BETWEEN']; - let p_mins = mins || [25.0, 55.0]; - let p_maxs = maxs || [null, 65.0]; - - // If no explicit filters provided, try to get from device reports - // if (!columns && !ops && !mins && !maxs) { - try { - // Get the first report for this device to extract alert points - const { data: reports, error: reportError } = await this.supabase - .from('reports') - .select('report_id, report_alert_points(*)') - .eq('dev_eui', devEui) - .limit(1); - - if (!reportError && reports && reports.length > 0 && reports[0].report_alert_points) { - const alertPoints = reports[0].report_alert_points; - if (alertPoints && alertPoints.length > 0) { - p_columns = []; - p_ops = []; - p_mins = []; - p_maxs = []; - - alertPoints.forEach((point: any) => { - if (point.data_point_key) { - if (p_columns && !p_columns.includes(point.data_point_key)) { - p_columns.push(point.data_point_key); - } - p_ops.push(point.operator || '>'); - p_mins.push(point.min || point.value || 0); - p_maxs.push(point.max || null); - } - }); - } - } - } catch (alertError) { - // If we can't get alert points, use default values - console.warn('Could not load alert points, using defaults:', alertError); - } - // } - - const { data, error: deviceError } = await this.supabase.rpc( - 'get_filtered_device_report_data_multi_v2', - { - p_dev_id: devEui, - p_start_time: startDate, - p_end_time: endDate, - p_interval_minutes: intervalMinutes, - p_columns: p_columns, - p_ops: p_ops, - p_mins: p_mins, - p_maxs: p_maxs, - p_timezone: timezone - } - ); - - console.log('Report RPC params', { - devEui, - start: startDate.toISOString(), - end: endDate.toISOString(), - intervalMinutes, - columns: p_columns, - ops: p_ops, - mins: p_mins, - maxs: p_maxs, - timezone - }); - - if (deviceError) { - this.errorHandler.logError(deviceError); - throw new Error(`Error fetching report data: ${deviceError.message}`); - } - if (!data || data.length === 0) { - return [ - { - error: 'No data found for the specified date range', - partial: false, - dev_eui: devEui, - created_at: new Date().toISOString(), - note: 'No data available for this device in the specified range' - } - ]; - } - - const records = data as DeviceDataRecord[]; - const hasTrafficHourField = records.some((record) => 'traffic_hour' in record); - const tableNameForConversion = hasTrafficHourField ? 'cw_traffic2' : 'report_data'; - return this.convertRecordTimestampsToUserTimezone(records, timezone, tableNameForConversion); - } catch (error) { - this.errorHandler.logError(error as Error); - if (error instanceof Error && error.message.includes('AbortError')) { - return [ - { - error: 'Data retrieval timed out', - partial: true, - dev_eui: devEui, - created_at: new Date().toISOString(), - note: 'This is a placeholder due to query timeout' - } - ]; - } - throw error; - } - } - - /** - * Get alert points for a device from its reports - * @param devEui The device EUI - */ - public async getAlertPointsForDevice(devEui: string): Promise { - if (!devEui) { - throw new Error('Device EUI not specified'); - } - if (!(await this.checkUserHasAccess(devEui))) { - throw new Error('User does not have access to this device'); - } - - try { - const { data: reports, error: reportError } = await this.supabase - .from('reports') - .select('report_id, name, report_alert_points(*)') - .eq('dev_eui', devEui) - .limit(1); - - if (!reportError && reports && reports.length > 0 && reports[0].report_alert_points) { - return reports[0].report_alert_points || []; - } - return []; - } catch (error) { - this.errorHandler.logError(error as Error); - return []; - } - } - - private async checkUserHasAccess(dev_eui: string): Promise { - const session = await this.supabase.auth.getSession(); - if (!session || !session.data.session) { - return false; // No session means no access - } - const user = session.data.session.user; - if (!user || !user.email) { - return false; // No user or email means no access - } - - // Device owner shortcut - const { data: deviceOwner, error: deviceOwnerError } = await this.supabase - .from('cw_devices') - .select('user_id') - .eq('dev_eui', dev_eui) - .maybeSingle(); - - if (deviceOwnerError && deviceOwnerError.code !== 'PGRST116') { - this.errorHandler.logError(deviceOwnerError); - return false; - } - if (deviceOwner?.user_id === user.id) { - return true; - } - - const { data, error } = await this.supabase - .from('cw_device_owners') - .select('id') - .eq('dev_eui', dev_eui) - .eq('user_id', user.id) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - this.errorHandler.logError(error); - return false; - } - - return Boolean(data); - } - - private aggregateTrafficCounts(records: Record[]): DeviceDataRecord[] { - if (!records || records.length === 0) { - return []; - } - - const grouped = new Map(); - - const toNumber = (value: unknown): number => { - if (typeof value === 'number') return value; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (trimmed.length === 0) return 0; - const parsed = Number(trimmed); - return Number.isNaN(parsed) ? 0 : parsed; - } - return 0; - }; - - for (const record of records) { - if (!record) continue; - - const devEui = record.dev_eui; - const trafficHour = record.traffic_hour; - if (!devEui || !trafficHour) continue; - - const key = `${devEui}__${trafficHour}`; - let aggregated = grouped.get(key); - - if (!aggregated) { - aggregated = { - dev_eui: devEui, - traffic_hour: trafficHour, - created_at: trafficHour - } as DeviceDataRecord; - grouped.set(key, aggregated); - } - - for (const [field, value] of Object.entries(record)) { - if (!field.endsWith('_count')) continue; - if (field === 'line_number') continue; - - const existing = (aggregated as Record)[field] ?? 0; - (aggregated as Record)[field] = existing + toNumber(value); - } - } - - const results = Array.from(grouped.values()); - results.sort((a, b) => { - const aDate = new Date((a.traffic_hour as string) ?? (a.created_at as string) ?? 0); - const bDate = new Date((b.traffic_hour as string) ?? (b.created_at as string) ?? 0); - return bDate.getTime() - aDate.getTime(); - }); - - const ensureFields = [ - 'people_count', - 'bicycle_count', - 'motorcycle_count', - 'car_count', - 'truck_count', - 'bus_count' - ]; - - return results.map((record) => { - const copy = { ...record } as Record; - for (const field of ensureFields) { - if (copy[field] === undefined || copy[field] === null) { - copy[field] = 0; - } - } - delete copy.line_number; - delete copy.id; - return copy as DeviceDataRecord; - }); - } - - private async getLatestTrafficAggregate(devEui: string): Promise { - const { data: latestRows, error } = await this.supabase - .from('cw_traffic2') - .select('*') - .eq('dev_eui', devEui) - .order('traffic_hour', { ascending: false }) - .limit(10); - - if (error) { - this.errorHandler.logError(error); - throw new Error(`Error fetching latest traffic data: ${error.message}`); - } - - if (!latestRows || latestRows.length === 0) { - return null; - } - - const latestHour = latestRows[0]?.traffic_hour; - if (!latestHour) { - return null; - } - - const rowsForHour = latestRows.filter((row) => row?.traffic_hour === latestHour); - - // If the limited query includes all lines for the hour, aggregate directly. - if (rowsForHour.length === latestRows.length) { - const [aggregated] = this.aggregateTrafficCounts(rowsForHour); - return aggregated ?? null; - } - - // Otherwise fetch all rows for the hour to guarantee completeness. - const { data: completeHourRows, error: hourError } = await this.supabase - .from('cw_traffic2') - .select('*') - .eq('dev_eui', devEui) - .eq('traffic_hour', latestHour); - - if (hourError) { - this.errorHandler.logError(hourError); - throw new Error(`Error fetching traffic data for hour ${latestHour}: ${hourError.message}`); - } - - const [aggregated] = this.aggregateTrafficCounts(completeHourRows ?? []); - return aggregated ?? null; - } - - /** - * Convert user timezone dates to UTC for database queries - * @param date Date in user's timezone - * @param timezone User's timezone string - * @returns Date converted to UTC - */ - private convertUserTimezoneToUTC(date: Date, timezone: string): Date { - if (timezone === 'UTC') { - return date; // No conversion needed - } - - // Create DateTime from the date's components in the specified timezone - const year = date.getFullYear(); - const month = date.getMonth() + 1; // getMonth() returns 0-11, DateTime expects 1-12 - const day = date.getDate(); - const hour = date.getHours(); - const minute = date.getMinutes(); - const second = date.getSeconds(); - - // Create DateTime in the user's timezone, then convert to UTC - const dt = DateTime.fromObject( - { - year, - month, - day, - hour, - minute, - second - }, - { zone: timezone } - ); - - return dt.toUTC().toJSDate(); - } - - /** - * Convert UTC timestamp back to user's timezone - * @param utcTimestamp UTC timestamp string - * @param timezone User's timezone string - * @returns Formatted timestamp in user's timezone - */ - private readonly timezoneOffsetPattern = /([zZ]|[+\-]\d{2}:?\d{2})$/; - - private convertUTCToUserTimezone(utcTimestamp: string | Date, timezone: string): string { - if (!utcTimestamp) { - return ''; - } - if (utcTimestamp instanceof Date) { - const asIso = DateTime.fromJSDate(utcTimestamp, { zone: 'utc' }).setZone(timezone).toISO(); - return asIso ?? utcTimestamp.toISOString(); - } - - const normalized = - typeof utcTimestamp === 'string' ? utcTimestamp.trim() : String(utcTimestamp); - if (!normalized) { - return normalized; - } - if (timezone === 'UTC') { - return normalized; - } - - const hasOffset = this.timezoneOffsetPattern.test(normalized); - let dt = DateTime.invalid('unparsed'); - - if (hasOffset) { - dt = DateTime.fromISO(normalized, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromSQL(normalized, { setZone: true }); - if (!dt.isValid) dt = DateTime.fromRFC2822(normalized, { setZone: true }); - } else { - dt = DateTime.fromISO(normalized, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromSQL(normalized, { zone: 'utc' }); - if (!dt.isValid) dt = DateTime.fromRFC2822(normalized, { zone: 'utc' }); - } - - if (!dt.isValid) { - console.warn(`Failed to parse timestamp: ${utcTimestamp}`); - return normalized; - } - - return dt.setZone(timezone).toISO() ?? normalized; - } - - /** - * Convert all timestamps in device data records from UTC to user timezone - * @param records Array of device data records - * @param timezone User's timezone string - * @param tableName Table name to determine timestamp field - * @returns Records with timestamps converted to user timezone - */ - private convertRecordTimestampsToUserTimezone( - records: DeviceDataRecord[], - timezone: string, - tableName: string - ): DeviceDataRecord[] { - if (timezone === 'UTC') { - return records; // No conversion needed - } - - return records.map((record) => { - const convertedRecord = { ...record }; - - // Convert main timestamp field - const timestampField = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - if (convertedRecord[timestampField]) { - convertedRecord[timestampField] = this.convertUTCToUserTimezone( - convertedRecord[timestampField] as string, - timezone - ); - } - - // Ensure created_at is always present and converted - if (convertedRecord.created_at) { - convertedRecord.created_at = this.convertUTCToUserTimezone( - convertedRecord.created_at, - timezone - ); - } - - return convertedRecord; - }); - } - - private toCSV(records: Record[], headers?: string[]): string { - if (!records || records.length === 0) { - return headers && headers.length ? headers.join(',') + '\n' : ''; - } - const cols = - headers && headers.length - ? headers - : Array.from(new Set(records.flatMap((r) => Object.keys(r)))); - - const escapeField = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'boolean') return val ? 't' : 'f'; - const s = String(val); - const needsQuote = /[",\r\n]/.test(s); - const escaped = s.replace(/"/g, '""'); - return needsQuote ? `"${escaped}"` : escaped; - }; - - const lines: string[] = []; - lines.push(cols.join(',')); - for (const rec of records) { - const row = cols.map((c) => escapeField(rec[c])); - lines.push(row.join(',')); - } - return lines.join('\n'); - } -} diff --git a/src/lib/services/DeviceOwnersService.ts b/src/lib/services/DeviceOwnersService.ts deleted file mode 100644 index eae2611e..00000000 --- a/src/lib/services/DeviceOwnersService.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import { DeviceOwnersRepository } from '../repositories/DeviceOwnersRepository'; -import type { - DeviceOwner, - DeviceOwnerInsert, - DeviceOwnerUpdate, - DeviceOwnerWithProfile -} from '../models/DeviceOwner'; - -/** - * Service for device owner business logic - */ -export class DeviceOwnersService { - /** - * Constructor with DeviceOwnersRepository dependency - */ - constructor(private repository: DeviceOwnersRepository) {} - - /** - * Get a device owner by ID - * @param id The device owner ID - */ - async getById(id: number): Promise { - return await this.repository.findById(id); - } - - /** - * Get all device owners - */ - async getAll(): Promise { - return await this.repository.findAll(); - } - - /** - * Get device owners by device EUI - * @param devEui The device EUI - */ - async getByDeviceEui(devEui: string): Promise { - return await this.repository.findByDeviceEui(devEui); - } - - /** - * Get device owners by user ID - * @param userId The user ID - */ - async getByUserId(userId: string): Promise { - return await this.repository.findByUserId(userId); - } - - /** - * Get device owners with profile information by device EUI - * @param devEui The device EUI - */ - async getWithProfilesByDeviceEui(devEui: string): Promise { - return await this.repository.findWithProfilesByDeviceEui(devEui); - } - - /** - * Get a specific device owner entry - * @param devEui The device EUI - * @param userId The user ID - */ - async getByDeviceAndUser(devEui: string, userId: string): Promise { - return await this.repository.findByDeviceAndUser(devEui, userId); - } - - /** - * Check if a user has permission for a device - * @param devEui The device EUI - * @param userId The user ID - * @param minimumPermission The minimum permission level required (optional) - */ - async hasPermission( - devEui: string, - userId: string, - minimumPermission?: number - ): Promise { - return await this.repository.hasPermission(devEui, userId, minimumPermission); - } - - /** - * Get devices owned by a user - * @param userId The user ID - */ - async getDevicesByUserId(userId: string): Promise { - return await this.repository.getDevicesByUserId(userId); - } - - /** - * Add a user to a device with a specified permission level - * @param devEui The device EUI - * @param userId The user ID - * @param permissionLevel The permission level to assign - */ - async addUserToDevice( - devEui: string, - userId: string, - permissionLevel: number - ): Promise { - // Check if the user is already an owner of this device - const existingOwner = await this.repository.findByDeviceAndUser(devEui, userId); - if (existingOwner) { - throw new Error(`User ${userId} is already an owner of device ${devEui}`); - } - - const deviceOwner: DeviceOwnerInsert = { - dev_eui: devEui, - user_id: userId, - permission_level: permissionLevel - }; - - return await this.repository.create(deviceOwner); - } - - /** - * Update a device owner's permission level - * @param id The device owner ID - * @param permissionLevel The new permission level - */ - async updatePermission(id: number, permissionLevel: number): Promise { - return await this.repository.updatePermission(id, permissionLevel); - } - - /** - * Update a device owner entry - * @param id The device owner ID - * @param updates The updates to apply - */ - async update(id: number, updates: DeviceOwnerUpdate): Promise { - return await this.repository.update(id, updates); - } - - /** - * Remove a user from a device - * @param devEui The device EUI - * @param userId The user ID to remove - */ - async removeUserFromDevice(devEui: string, userId: string): Promise { - await this.repository.deleteByDeviceAndUser(devEui, userId); - } - - /** - * Remove a device owner by ID - * @param id The device owner ID - */ - async remove(id: number): Promise { - await this.repository.delete(id); - } - - /** - * Transfer device ownership - * @param devEui The device EUI - * @param fromUserId The current owner's user ID - * @param toUserId The new owner's user ID - * @param permissionLevel The permission level for the new owner - */ - async transferOwnership( - devEui: string, - fromUserId: string, - toUserId: string, - permissionLevel: number = 100 - ): Promise { - // Check if the target user is already an owner - const existingOwner = await this.repository.findByDeviceAndUser(devEui, toUserId); - if (existingOwner) { - throw new Error(`User ${toUserId} is already an owner of device ${devEui}`); - } - - // Add the new owner - await this.addUserToDevice(devEui, toUserId, permissionLevel); - - // Remove the old owner - await this.removeUserFromDevice(devEui, fromUserId); - } - - /** - * Get the highest permission level for a user on a device - * @param devEui The device EUI - * @param userId The user ID - */ - async getUserPermissionLevel(devEui: string, userId: string): Promise { - const deviceOwner = await this.repository.findByDeviceAndUser(devEui, userId); - return deviceOwner?.permission_level || null; - } - - /** - * Check if a user is the primary owner (highest permission) of a device - * @param devEui The device EUI - * @param userId The user ID - */ - async isPrimaryOwner(devEui: string, userId: string): Promise { - const owners = await this.repository.findByDeviceEui(devEui); - if (owners.length === 0) return false; - - const userOwner = owners.find((owner) => owner.user_id === userId); - if (!userOwner) return false; - - const maxPermission = Math.max(...owners.map((owner) => owner.permission_level)); - return userOwner.permission_level === maxPermission; - } -} diff --git a/src/lib/services/DeviceService.ts b/src/lib/services/DeviceService.ts deleted file mode 100644 index 6bc337eb..00000000 --- a/src/lib/services/DeviceService.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { IDeviceService } from '../interfaces/IDeviceService'; -import { DeviceRepository } from '../repositories/DeviceRepository'; -import type { Device, DeviceInsert, DeviceUpdate, DeviceWithType } from '../models/Device'; -import type { DeviceWithJoins } from '../repositories/DeviceRepository'; - -/** - * Implementation of DeviceService - * This service handles all business logic related to devices - */ -export class DeviceService implements IDeviceService { - /** - * Constructor with DeviceRepository dependency - */ - constructor(private deviceRepository: DeviceRepository) {} - - /** - * Get a device by its EUI - * @param devEui The device EUI - */ - async getDeviceByEui(devEui: string): Promise { - return this.deviceRepository.findById(devEui); - } - - /** - * Get a device with its type information - * @param devEui The device EUI - */ - async getDeviceWithTypeByEui(devEui: string): Promise { - return this.deviceRepository.getDeviceWithType(devEui); - } - - /** - * Get all devices - */ - async getAllDevices(user_id: string): Promise { - const devices = this.deviceRepository.findAllDevicesByOwner(user_id); - - return devices; - } - - /** - * Get devices by location ID - * @param locationId The location ID - */ - async getDevicesByLocation(locationId: number): Promise { - return this.deviceRepository.findByLocation(locationId); - } - - /** - * Get devices by type ID - * @param typeId The device type ID - */ - async getDevicesByType(typeId: number): Promise { - return this.deviceRepository.findByType(typeId); - } - - /** - * Create a new device - * @param device The device to create - */ - async createDevice(device: DeviceInsert): Promise { - return this.deviceRepository.create(device); - } - - /** - * Update an existing device - * @param devEui The device EUI - * @param device The device with updated values - */ - async updateDevice(devEui: string, device: DeviceUpdate): Promise { - return this.deviceRepository.update(devEui, device); - } - - /** - * Delete a device - * @param devEui The device EUI - */ - async deleteDevice(devEui: string): Promise { - return this.deviceRepository.delete(devEui); - } -} diff --git a/src/lib/services/GatewayService.ts b/src/lib/services/GatewayService.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/services/LocationService.ts b/src/lib/services/LocationService.ts deleted file mode 100644 index 3cf77967..00000000 --- a/src/lib/services/LocationService.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { ILocationService } from '../interfaces/ILocationService'; -import { LocationRepository } from '../repositories/LocationRepository'; -import type { Location, LocationInsert, LocationUpdate } from '../models/Location'; -import type { LocationUser } from '../models/LocationUser'; -import { DeviceRepository } from '../repositories/DeviceRepository'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import { info } from '../utilities/logger'; - -/** - * Implementation of LocationService - * This service handles all business logic related to locations - */ -export class LocationService implements ILocationService { - /** - * Constructor with repository dependencies - */ - constructor( - private locationRepository: LocationRepository, - private deviceRepository: DeviceRepository, - private errorHandler: ErrorHandlingService = new ErrorHandlingService() - ) { - info('starting location service...'); - } - - /** - * Get a location by its ID - * @param locationId The location ID - */ - async getLocationById(locationId: number): Promise { - return this.locationRepository.findById(locationId); - } - - /** - * Get all locations - */ - async getAllLocations(): Promise { - return this.locationRepository.findAll(); - } - - /** - * Get locations by owner ID - * @param ownerId The owner ID - */ - async getLocationsByOwner(ownerId: string): Promise { - return this.locationRepository.findByOwnerId(ownerId); - } - - /** - * Create a new location - * @param location The location to create - */ - async createLocation(location: LocationInsert): Promise { - return this.locationRepository.create(location); - } - - /** - * Update an existing location - * @param locationId The location ID - * @param location The location with updated values - */ - async updateLocation(locationId: number, location: LocationUpdate): Promise { - return this.locationRepository.update(locationId, location); - } - - /** - * Delete a location - * @param locationId The location ID - */ - async deleteLocation(locationId: number, userId: string): Promise { - const location = await this.locationRepository.findById(locationId); - if (!location) { - throw new Error(`Location with ID ${locationId} not found`); - } - if (location.owner_id !== userId) { - throw new Error(`User ${userId} does not have permission to delete this location`); - } - return this.locationRepository.delete(locationId); - } - - /** - * Get the count of devices for a specific location - * @param locationId The location ID - */ - async getDeviceCountForLocation(locationId: number): Promise { - const devices = await this.deviceRepository.findByLocation(locationId); - return devices.length; - } - - /** - * Get all users with access to a location - * @param locationId The location ID - */ - async getLocationUsers(locationId: number): Promise { - return this.locationRepository.getLocationUsers(locationId); - } - - /** - * Check if a user has sufficient permissions to access a location - * @param locationId The location ID - * @param userId The user ID - * @param requiredLevel The minimum required permission level (defaults to 3 - Viewer) - */ - async checkUserLocationPermission( - locationId: number, - userId: string, - requiredLevel: number = 3 - ): Promise { - return this.locationRepository.checkUserPermission(locationId, userId, requiredLevel); - } - - /** - * Add a user to a location with specified permission level - * @param locationId The location ID - * @param email The email of the user to add - * @param permissionLevel The permission level to assign - * @param applyToDevices Whether to apply the same permission to all devices in the location - */ - async addUserToLocation( - locationId: number, - email: string, - permissionLevel: number, - applyToDevices: boolean - ): Promise<{ success: boolean; error?: string; warning?: string }> { - try { - // First, find the user by email - const userToAdd = await this.locationRepository.findUserByEmail(email); - - if (!userToAdd) { - return { success: false, error: 'User not found' }; - } - - // Check if the user already has permissions for this location - const existingPerm = await this.locationRepository.findLocationOwner( - locationId, - userToAdd.id - ); - - if (existingPerm) { - return { success: false, error: 'User already has permissions for this location' }; - } - - // Add user to location - await this.locationRepository.addUserToLocation(locationId, userToAdd.id, permissionLevel); - - // If applyToDevices is true, apply the same permission to all devices in the location - const devices = await this.deviceRepository.findByLocation(locationId); - - if (!devices || devices.length === 0) { - return { success: true }; - } - - if (applyToDevices) { - // Add user to all devices with the same permission level - await Promise.all( - devices.map((device) => - this.deviceRepository.addUserToDevice(device.dev_eui, userToAdd.id, permissionLevel) - ) - ); - } else { - // Add user to all devices with 'Disabled' permission (level 4) - await Promise.all( - devices.map( - (device) => this.deviceRepository.addUserToDevice(device.dev_eui, userToAdd.id, 255) // Hardcoded 'Disabled' permission - // TODO: Move permission level to a constant or enum - ) - ); - } - - return { success: true }; - } catch (error) { - this.errorHandler.logError(error as Error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to add user to location' - }; - } - } - - /** - * Update a user's permission for a location - * @param locationId The location ID - * @param userId The user ID to update - * @param locationOwnerId The location owner entry ID - * @param permissionLevel The new permission level - * @param applyToDevices Whether to apply the same permission to all devices in the location - */ - async updateUserPermission( - locationId: number, - userId: string, - locationOwnerId: number, - permissionLevel: number, - applyToDevices: boolean - ): Promise<{ success: boolean; error?: string; warning?: string }> { - try { - // Update user's location permission - await this.locationRepository.updateUserPermission(locationOwnerId, permissionLevel); - - // If applyToDevices is true, update all device permissions for this user - if (applyToDevices) { - // Get all devices in this location - const devices = await this.deviceRepository.findByLocation(locationId); - - if (!devices || devices.length === 0) { - return { success: true }; - } - - await Promise.all( - devices.map(async (device) => { - // Check if user has existing permission for this device - const existingPermission = await this.deviceRepository.findDeviceOwner( - device.dev_eui, - userId - ); - - if (existingPermission) { - // Update existing permission - if (typeof existingPermission.id === 'number') { - await this.deviceRepository.updateDevicePermission( - existingPermission.id, - permissionLevel - ); - } else { - // Direct owners already have full access; nothing to update - } - } else { - // Create new permission - await this.deviceRepository.addUserToDevice(device.dev_eui, userId, permissionLevel); - } - }) - ); - } - - return { success: true }; - } catch (error) { - this.errorHandler.logError(error as Error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to update permission' - }; - } - } - - /** - * Remove a user from a location and its devices - * @param locationId The location ID - * @param userId The user ID to remove - * @param locationOwnerId The location owner entry ID - * @param me The ID of the user performing the action (to check if they are removing themselves) - */ - async removeUserFromLocation( - locationId: number, - userId: string, - locationOwnerId: number, - me: string - ): Promise<{ success: boolean; error?: string }> { - try { - // Check if user is attempting to remove themselves as admin - const userPermission = await this.locationRepository.getLocationOwnerById(locationOwnerId); - - if (userPermission?.permission_level === 1) { - // Get count of other admins - const otherAdmins = await this.locationRepository.countOtherAdmins(locationId, userId); - - if (otherAdmins === 0) { - return { - success: false, - error: 'Cannot remove yourself as admin. Assign another admin first.' - }; - } - } - - await this.locationRepository.removeUserFromLocationByUserId(userId, locationId); - // The user no longer has access to this location, thus they will no longer have access to any devices in this location. - // Delete the permission to all devices for this user too - - // Get all devices in this location to remove user from device permissions - const devices = await this.deviceRepository.findByLocation(locationId); - - if (devices && devices.length > 0) { - await Promise.all( - devices.map((device) => - this.deviceRepository.removeUserFromDevice(device.dev_eui, userId) - ) - ); - } - - return { success: true }; - } catch (error) { - this.errorHandler.logError(error as Error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to remove user from location' - }; - } - } -} diff --git a/src/lib/services/OneSignalService.ts b/src/lib/services/OneSignalService.ts deleted file mode 100644 index 458c469e..00000000 --- a/src/lib/services/OneSignalService.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -// SvelteKit env imports. Public App ID may be used on client; REST key must stay private. -import { PUBLIC_ONESIGNAL_APP_ID, PUBLIC_ONESIGNAL_SAFARI_WEB_ID } from '$env/static/public'; -import { PRIVATE_ONESIGNAL_REST_API_KEY } from '$env/static/private'; - -/** - * OneSignal push helper (2025 docs: Create message API) - * Env vars (SvelteKit style): - * PUBLIC_ONESIGNAL_APP_ID (public) - * PUBLIC_ONESIGNAL_SAFARI_WEB_ID (public, optional for Safari web push) - * PRIVATE_ONESIGNAL_REST_API_KEY (server only) - */ -export class OneSignalService { - constructor(private errorHandler: ErrorHandlingService) {} - - private assertEnv() { - if (!PUBLIC_ONESIGNAL_APP_ID) throw new Error('PUBLIC_ONESIGNAL_APP_ID not configured'); - if (!PRIVATE_ONESIGNAL_REST_API_KEY) - throw new Error('PRIVATE_ONESIGNAL_REST_API_KEY not configured'); - } - - private baseHeaders() { - this.assertEnv(); - return { - 'content-type': 'application/json; charset=utf-8', - authorization: `Key ${PRIVATE_ONESIGNAL_REST_API_KEY}` - }; - } - - /** - * Send a push notification. - * Provide either externalUserIds (preferred) or includedSegments. - */ - async sendPush(options: { - headings?: Record; - contents: Record; - externalUserIds?: string[]; // OneSignal External IDs - includedSegments?: string[]; // e.g. ['Test Users'] - data?: Record; - iosAttachments?: Record; - bigPicture?: string; // Android image - name?: string; // internal name in dashboard - }): Promise { - try { - this.assertEnv(); - const { - headings, - contents, - externalUserIds, - includedSegments, - data, - iosAttachments, - bigPicture, - name - } = options; - - if (!contents || Object.keys(contents).length === 0) { - throw new Error('contents is required'); - } - if (!externalUserIds?.length && !includedSegments?.length) { - throw new Error('Must supply externalUserIds or includedSegments'); - } - - const body: any = { - app_id: PUBLIC_ONESIGNAL_APP_ID, - target_channel: 'push', - contents, - headings, - data, - name - }; - if (externalUserIds?.length) body.include_external_user_ids = externalUserIds; - if (includedSegments?.length) body.included_segments = includedSegments; - if (iosAttachments) body.ios_attachments = iosAttachments; - if (bigPicture) body.big_picture = bigPicture; - - const res = await fetch('https://api.onesignal.com/notifications', { - method: 'POST', - headers: this.baseHeaders(), - body: JSON.stringify(body) - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`OneSignal error ${res.status}: ${text}`); - } - return await res.json(); - } catch (err) { - this.errorHandler.logError(err as Error); - throw err; - } - } -} - -// Optional helper for web push initialization (Safari ID is exposed for completeness) -export const ONE_SIGNAL_PUBLIC_CONFIG = { - appId: PUBLIC_ONESIGNAL_APP_ID, - safari_web_id: PUBLIC_ONESIGNAL_SAFARI_WEB_ID -}; diff --git a/src/lib/services/ReportService.ts b/src/lib/services/ReportService.ts deleted file mode 100644 index 4e805a11..00000000 --- a/src/lib/services/ReportService.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { IReportService } from '../interfaces/IReportService'; -import { ReportRepository } from '../repositories/ReportRepository'; -import { ReportAlertPointRepository } from '../repositories/ReportAlertPointRepository'; -import { ReportRecipientRepository } from '../repositories/ReportRecipientRepository'; -import { ReportUserScheduleRepository } from '../repositories/ReportUserScheduleRepository'; -import type { - Report, - ReportInsert, - ReportUpdate, - ReportWithDetails, - ReportWithRecipients, - ReportAlertPoint, - ReportAlertPointInsert, - ReportAlertPointUpdate, - ReportRecipient, - ReportRecipientInsert, - ReportRecipientUpdate, - ReportUserSchedule, - ReportUserScheduleInsert, - ReportUserScheduleUpdate -} from '../models/Report'; -import type { DeviceRepository } from '$lib/repositories/DeviceRepository'; - -/** - * Implementation of ReportService - * This service handles all business logic related to reports - */ -export class ReportService implements IReportService { - constructor( - private reportRepository: ReportRepository, - private alertPointRepository: ReportAlertPointRepository, - private recipientRepository: ReportRecipientRepository, - private scheduleRepository: ReportUserScheduleRepository, - private deviceRepository: DeviceRepository - ) {} - - /** - * Get all reports - */ - async getAllReports(devEui: string): Promise { - return this.reportRepository.findAll(); - } - - /** - * Get all reports with devices - */ - async getAllReportsWithDevices(devEui: string): Promise { - let allReports = await this.reportRepository.findAll(); - for (let report of allReports) { - report['cw_device'] = await this.deviceRepository.findById(report.dev_eui); - } - return allReports; - } - - /** - * Get reports by device EUI - */ - async getReportsByDeviceEui(devEui: string): Promise { - return this.reportRepository.findByDeviceEui(devEui); - } - - /** - * Get report by report ID - */ - async getReportByReportId(reportId: string): Promise { - return this.reportRepository.findByReportId(reportId); - } - - /** - * Get reports with full details by device EUI - */ - async getReportsWithDetailsByDeviceEui(devEui: string): Promise { - return this.reportRepository.findWithDetailsByDeviceEui(devEui); - } - - /** - * Get reports with recipients by device EUI - */ - async getReportsWithRecipientsByDeviceEui(devEui: string): Promise { - return this.reportRepository.findWithRecipientsByDeviceEui(devEui); - } - - /** - * Create a new report - */ - async createReport(report: ReportInsert): Promise { - // Generate a unique report ID if not provided - // if (!report.report_id) { - // report.report_id = `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - // } - - return this.reportRepository.create(report as any); - } - - /** - * Update a report - */ - async updateReport(reportId: string, updates: ReportUpdate): Promise { - const updatedReport = await this.reportRepository.update(reportId, updates as any); - if (!updatedReport) { - throw new Error(`Report not found: ${reportId}`); - } - return updatedReport; - } - - /** - * Upsert a report (create or update) - */ - async upsertReport(report: ReportInsert): Promise { - return this.reportRepository.upsert(report as any); - } - - /** - * Delete a report and all related data - */ - async deleteReport(reportId: string): Promise { - // Delete related data first (order matters for foreign key constraints) - await this.deleteAlertPointsByReportId(reportId); - await this.deleteRecipientsByReportId(reportId); - await this.deleteSchedulesByReportId(reportId); - - // Finally delete the report - await this.reportRepository.deleteByReportId(reportId); - } - - /** - * Count reports for a device - */ - async countReportsByDeviceEui(devEui: string): Promise { - return this.reportRepository.countByDeviceEui(devEui); - } - - /** - * Get alert points for a report - */ - async getAlertPointsByReportId(reportId: string): Promise { - return this.alertPointRepository.findByReportId(reportId); - } - - /** - * Create an alert point - */ - async createAlertPoint(alertPoint: ReportAlertPointInsert): Promise { - return this.alertPointRepository.createAlertPoint(alertPoint); - } - - /** - * Update an alert point - */ - async updateAlertPoint(id: number, updates: ReportAlertPointUpdate): Promise { - return this.alertPointRepository.updateAlertPoint(id, updates); - } - - /** - * Upsert an alert point - */ - async upsertAlertPoint(alertPoint: ReportAlertPointInsert): Promise { - return this.alertPointRepository.upsertAlertPoint(alertPoint); - } - - /** - * Delete alert points for a report - */ - async deleteAlertPointsByReportId(reportId: string): Promise { - return this.alertPointRepository.deleteByReportId(reportId); - } - - /** - * Get recipients for a report - */ - async getRecipientsByReportId(reportId: string): Promise { - return this.recipientRepository.findByReportId(reportId); - } - - /** - * Create a recipient - */ - async createRecipient(recipient: ReportRecipientInsert): Promise { - return this.recipientRepository.createRecipient(recipient); - } - - /** - * Update a recipient - */ - async updateRecipient(id: number, updates: ReportRecipientUpdate): Promise { - return this.recipientRepository.updateRecipient(id, updates); - } - - /** - * Upsert a recipient - */ - async upsertRecipient(recipient: ReportRecipientInsert): Promise { - return this.recipientRepository.upsertRecipient(recipient); - } - - /** - * Delete recipients for a report - */ - async deleteRecipientsByReportId(reportId: string): Promise { - return this.recipientRepository.deleteByReportId(reportId); - } - - /** - * Delete a specific recipient - */ - async deleteRecipient(id: number): Promise { - return this.recipientRepository.deleteRecipient(id); - } - - /** - * Get schedules for a report - */ - async getSchedulesByReportId(reportId: string): Promise { - return this.scheduleRepository.findByReportId(reportId); - } - - /** - * Get schedules by device EUI - */ - async getSchedulesByDeviceEui(devEui: string): Promise { - return this.scheduleRepository.findByDeviceEui(devEui); - } - - /** - * Get active schedules for a user - */ - async getActiveSchedulesByUserId(userId: string): Promise { - return this.scheduleRepository.findActiveByUserId(userId); - } - - /** - * Create a schedule - */ - async createSchedule(schedule: ReportUserScheduleInsert): Promise { - return this.scheduleRepository.createSchedule(schedule); - } - - /** - * Update a schedule - */ - async updateSchedule(id: number, updates: ReportUserScheduleUpdate): Promise { - return this.scheduleRepository.updateSchedule(id, updates); - } - - /** - * Upsert a schedule - */ - async upsertSchedule(schedule: ReportUserScheduleInsert): Promise { - return this.scheduleRepository.upsertSchedule(schedule); - } - - /** - * Delete schedules for a report - */ - async deleteSchedulesByReportId(reportId: string): Promise { - return this.scheduleRepository.deleteByReportId(reportId); - } - - /** - * Activate/deactivate a schedule - */ - async setScheduleActive(id: number, isActive: boolean): Promise { - return this.scheduleRepository.setScheduleActive(id, isActive); - } -} diff --git a/src/lib/services/ReportTemplateService.ts b/src/lib/services/ReportTemplateService.ts deleted file mode 100644 index d18ee7e5..00000000 --- a/src/lib/services/ReportTemplateService.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ReportTemplateRepository } from '../repositories/ReportTemplateRepository'; -import type { - ReportTemplate, - ReportTemplateInsert, - ReportTemplateUpdate -} from '../models/ReportTemplate'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; - -export class ReportTemplateService { - constructor( - private repo: ReportTemplateRepository, - private errorHandler: ErrorHandlingService = new ErrorHandlingService() - ) {} - - async getUserReports(userId: string): Promise { - return this.repo.findByOwner(userId); - } - - async getReport(id: number): Promise { - return this.repo.findById(id); - } - - async createReport(report: ReportTemplateInsert): Promise { - return this.repo.create(report); - } - - async updateReport(id: number, report: ReportTemplateUpdate): Promise { - return this.repo.update(id, report); - } - - async deleteReport(id: number): Promise { - return this.repo.delete(id); - } -} diff --git a/src/lib/services/RuleService.ts b/src/lib/services/RuleService.ts deleted file mode 100644 index 67213588..00000000 --- a/src/lib/services/RuleService.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { IRuleService } from '../interfaces/IRuleService'; -import { RuleRepository } from '../repositories/RuleRepository'; -import type { - Rule, - RuleInsert, - RuleUpdate, - RuleCriteria, - RuleCriteriaInsert, - RuleCriteriaUpdate, - RuleWithCriteria -} from '../models/Rule'; - -/** - * Implementation of RuleService - * This service handles all business logic related to rules and criteria - */ -export class RuleService implements IRuleService { - /** - * Constructor with RuleRepository dependency - */ - constructor(private ruleRepository: RuleRepository) {} - - /** - * Get a rule by its ID - * @param id The rule ID - */ - async getRuleById(id: number): Promise { - return this.ruleRepository.findById(id); - } - - /** - * Get a rule with its criteria by group ID - * @param ruleGroupId The rule group ID - */ - async getRuleWithCriteria(ruleGroupId: string): Promise { - return this.ruleRepository.findRuleWithCriteria(ruleGroupId); - } - - /** - * Get rules by device EUI - * @param devEui The device EUI - */ - async getRulesByDevice(devEui: string): Promise { - return this.ruleRepository.findByDevice(devEui); - } - - /** - * Get rules with Criteria by device EUI - * @param devEui The device EUI - */ - getRulesAndCriteriaByDevice(devEui: string): Promise { - return this.ruleRepository.getRulesAndCriteriaByDevice(devEui); - } - - /** - * Get rules by profile ID - * @param profileId The profile ID - */ - async getRulesByProfile(profileId: string): Promise { - return this.ruleRepository.findByProfile(profileId); - } - - /** - * Get rule criteria by rule group ID - * @param ruleGroupId The rule group ID - */ - async getRuleCriteriaByGroup(ruleGroupId: string): Promise { - return this.ruleRepository.findCriteriaByGroup(ruleGroupId); - } - - /** - * Create a new rule - * @param rule The rule to create - */ - async createRule(rule: RuleInsert): Promise { - return this.ruleRepository.create(rule); - } - - /** - * Create a new rule criteria - * @param criteria The rule criteria to create - */ - async createRuleCriteria(criteria: RuleCriteriaInsert): Promise { - return this.ruleRepository.createCriteria(criteria); - } - - /** - * Create a complete rule with criteria - * @param rule The rule to create - * @param criteria Array of criteria to create for the rule - */ - async createRuleWithCriteria( - rule: RuleInsert, - criteria: RuleCriteriaInsert[] - ): Promise { - // First create the rule - const createdRule = await this.createRule(rule); - - // Then create all the criteria - const createdCriteria: RuleCriteria[] = []; - - for (const criterion of criteria) { - // Ensure all criteria reference the same rule group ID - const criterionWithGroup = { - ...criterion, - ruleGroupId: createdRule.ruleGroupId - }; - - const created = await this.createRuleCriteria(criterionWithGroup); - createdCriteria.push(created); - } - - // Return the combined result - return { - ...createdRule, - criteria: createdCriteria - }; - } - - /** - * Update an existing rule - * @param id The rule ID - * @param rule The rule with updated values - */ - async updateRule(id: number, rule: RuleUpdate): Promise { - return this.ruleRepository.update(id, rule); - } - - /** - * Update an existing rule criteria - * @param id The criteria ID - * @param criteria The criteria with updated values - */ - async updateRuleCriteria(id: number, criteria: RuleCriteriaUpdate): Promise { - return this.ruleRepository.updateCriteria(id, criteria); - } - - /** - * Delete a rule and its associated criteria - * @param id The rule ID - */ - async deleteRule(id: number): Promise { - // First get the rule to find its group ID - const rule = await this.getRuleById(id); - - if (!rule) { - // Rule doesn't exist, so it's already effectively deleted - return true; - } - - // Delete all criteria associated with this rule - await this.ruleRepository.deleteCriteriaByGroup(rule.ruleGroupId); - - // Then delete the rule itself - return this.ruleRepository.delete(id); - } - - /** - * Delete a rule criteria - * @param id The criteria ID - */ - async deleteRuleCriteria(id: number): Promise { - return this.ruleRepository.deleteCriteria(id); - } -} diff --git a/src/lib/services/SessionService.ts b/src/lib/services/SessionService.ts deleted file mode 100644 index 0d70663f..00000000 --- a/src/lib/services/SessionService.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ISessionService } from '../interfaces/ISessionService'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Session, User } from '@supabase/supabase-js'; -import { ErrorHandlingService } from '../errors/ErrorHandlingService'; -import { info, debug } from '../utilities/logger'; - -/** - * Service for handling session-related operations - * Refactored to accept a Supabase client directly instead of using IoC injection - */ -export class SessionService implements ISessionService { - private supabaseClient: SupabaseClient; - private readonly errorHandler = new ErrorHandlingService(); - - constructor(supabaseClient: SupabaseClient) { - this.supabaseClient = supabaseClient; - } - - /** - * Gets a validated session by checking both getSession and getUser - * Ensures the JWT token is valid before returning the session - */ - async getSafeSession(): Promise<{ session: Session | null; user: User | null }> { - try { - // First, get the current session - const { - data: { session } - } = await this.supabaseClient.auth.getSession(); - - if (!session) { - debug('SessionService: No session found'); - return { session: null, user: null }; - } - - // Validate the JWT by getting the user - const { - data: { user }, - error - } = await this.supabaseClient.auth.getUser(); - - if (error) { - this.errorHandler.logError(error); - return { session: null, user: null }; - } - - info('SessionService: Session found and validated for user:', user?.email ?? 'unknown'); - return { session, user }; - } catch (error) { - this.errorHandler.logError(error as Error); - return { session: null, user: null }; - } - } -} diff --git a/src/lib/services/SubscriptionService.ts b/src/lib/services/SubscriptionService.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/lib/stores/DashboardUIStore.svelte.ts b/src/lib/stores/DashboardUIStore.svelte.ts deleted file mode 100644 index f00fa2c0..00000000 --- a/src/lib/stores/DashboardUIStore.svelte.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { browser } from '$app/environment'; - -/** - * Store for managing dashboard UI preferences - */ -export class DashboardUIStore { - search = $state(browser ? (localStorage.getItem('dashboard_search') ?? '') : ''); - - // Method to explicitly clear search - clearSearch() { - //console.log('DashboardUIStore: clearSearch called'); - this.search = ''; - if (browser) { - localStorage.removeItem('dashboard_search'); - } - //console.log('DashboardUIStore: search cleared'); - } - - hideEmptyLocations = $state( - browser ? localStorage.getItem('hide_empty_locations') === 'true' : false - ); - - dashboardViewType: 'grid' | 'mozaic' | 'list' = $state( - browser - ? localStorage.getItem('dashboard_view_type') === 'mozaic' - ? 'mozaic' - : localStorage.getItem('dashboard_view_type') === 'list' - ? 'list' - : 'grid' - : 'grid' - ); - - dashboardSortType: 'alpha' | 'custom' = $state( - browser - ? localStorage.getItem('dashboard_sort_type') === 'alpha' - ? 'alpha' - : 'custom' - : 'alpha' - ); -} - -// Singleton instance -let dashboardUIStore: DashboardUIStore; - -/** - * Get the dashboard UI store instance - */ -export function getDashboardUIStore() { - if (!dashboardUIStore) { - dashboardUIStore = new DashboardUIStore(); - } - return dashboardUIStore; -} - -// The persistence effect should be set up in a Svelte component file -// rather than at the module level in a .svelte.ts file diff --git a/src/lib/stores/LocationsStore.svelte.ts b/src/lib/stores/LocationsStore.svelte.ts deleted file mode 100644 index f880dbc4..00000000 --- a/src/lib/stores/LocationsStore.svelte.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { Location } from '$lib/models/Location'; -import type { DeviceWithType } from '$lib/models/Device'; -import type { AirData } from '$lib/models/AirData'; -import type { SoilData } from '$lib/models/SoilData'; - -// Define DeviceWithSensorData type for use in the store -interface DeviceWithSensorData extends DeviceWithType { - latestData: AirData | SoilData | null; - cw_rules?: any[]; -} - -// Store state -let locations = $state<(Location & { deviceCount: number; cw_devices: DeviceWithSensorData[] })[]>( - [] -); -let selectedLocationId = $state(null); -let loadingLocations = $state(true); -let loadingDevices = $state(false); -let locationError = $state(null); -let deviceError = $state(null); -let devices = $state([]); - -// Derived state -const selectedLocation = $derived( - locations.find((loc) => loc.location_id === selectedLocationId) || null -); - -// Function to fetch all locations for a user -async function fetchLocations(userId: string) { - try { - loadingLocations = true; - locationError = null; - - const response = await fetch(`/api/locations?userId=${userId}`); - if (!response.ok) throw new Error('Failed to fetch location data'); - - const userLocations = await response.json(); - - // Process locations and load devices for each location - const locationPromises = userLocations.map(async (location: Location) => { - try { - // Fetch devices for this location - const devicesResponse = await fetch(`/api/locations/${location.location_id}/devices`); - if (!devicesResponse.ok) return { ...location, deviceCount: 0, cw_devices: [] }; - - const locationDevices = await devicesResponse.json(); - - return { - ...location, - deviceCount: locationDevices.length, - cw_devices: locationDevices - }; - } catch (error) { - console.error(`Error loading devices for location ${location.location_id}:`, error); - return { ...location, deviceCount: 0, cw_devices: [] }; - } - }); - - // Wait for all location device data to be loaded - locations = await Promise.all(locationPromises); - loadingLocations = false; - - // Select "All Locations" by default (null) - await selectLocation(null); - //console.log('LocationsStore: Initially selected All Locations (null)'); - // Note: We're no longer auto-selecting the first location by default - - return locations; - } catch (err) { - locationError = err instanceof Error ? err.message : 'Unknown error occurred'; - loadingLocations = false; - return []; - } -} - -// Function to select a location and load its devices -async function selectLocation(locationId: number | null) { - selectedLocationId = locationId; - if (locationId === null) { - // For "All Locations", aggregate devices from all locations - await loadAllDevices(); - } else { - // For specific location, load devices for that location - await loadDevicesForLocation(locationId); - } - return devices; -} - -// Function to load all devices from all locations -async function loadAllDevices() { - try { - loadingDevices = true; - deviceError = null; - - const allDevices: DeviceWithSensorData[] = []; - for (const location of locations) { - if (location.cw_devices && location.cw_devices.length > 0) { - allDevices.push(...location.cw_devices); - } - } - - devices = allDevices; - loadingDevices = false; - return devices; - } catch (error) { - console.error('Error loading all devices:', error); - deviceError = 'Failed to load devices from all locations'; - loadingDevices = false; - return []; - } -} - -// Function to load devices for a specific location -async function loadDevicesForLocation(locationId: number) { - try { - // Reset any previous device error - deviceError = null; - - // Show loading indicator for devices - loadingDevices = true; - devices = []; - - // Fetch devices for the selected location - const response = await fetch(`/api/locations/${locationId}/devices`); - if (!response.ok) throw new Error('Failed to fetch devices'); - - const locationDevices = await response.json(); - devices = locationDevices; - - // Update the location's devices to trigger UI refresh - updateLocationDevices(locationId, devices); - - loadingDevices = false; - return devices; - } catch (err) { - deviceError = err instanceof Error ? err.message : 'Unknown error occurred'; - loadingDevices = false; - return []; - } -} - -// Function to refresh devices for a location without changing the selected location -async function refreshDevicesForLocation(locationId: number) { - try { - // Fetch devices for the selected location - const response = await fetch(`/api/locations/${locationId}/devices`); - if (!response.ok) throw new Error('Failed to fetch devices'); - - const locationDevices = await response.json(); - devices = locationDevices; - - // Update the location's devices to trigger UI refresh - updateLocationDevices(locationId, devices); - - return true; - } catch (err) { - console.error('Error refreshing devices:', err); - return false; - } -} - -// Function to update the location's devices and trigger a UI refresh -function updateLocationDevices(locationId: number, updatedDevices: DeviceWithSensorData[]) { - if (locations && locations.length > 0) { - const currentLocationIndex = locations.findIndex((loc) => loc.location_id === locationId); - if (currentLocationIndex >= 0) { - locations[currentLocationIndex].cw_devices = [...updatedDevices]; - } - } -} - -function updateSingleDevice(devEui: string, updatedData: AirData | SoilData) { - if (!devEui || !updatedData) return; - - // Filter out null values and unwanted properties - const newData = Object.fromEntries( - Object.entries(updatedData).filter( - ([k, v]) => v != null && k !== 'is_simulated' && k !== 'dev_eui' && k !== 'line_number' - ) - ) as AirData | SoilData; - - console.log('Updating device:', devEui, 'with data:', newData); - // Update device in the devices array (current view) - if (devices && devices.length > 0) { - const deviceIndex = devices.findIndex((dev) => dev.dev_eui === devEui); - if (deviceIndex >= 0) { - // Create a new device object to ensure reactivity - devices[deviceIndex] = { - ...devices[deviceIndex], - latestData: newData - }; - } - } - - // Also update the device in the locations array to keep data consistent - if (locations && locations.length > 0) { - for (const location of locations) { - if (location.cw_devices && location.cw_devices.length > 0) { - const deviceIndex = location.cw_devices.findIndex((dev) => dev.dev_eui === devEui); - if (deviceIndex >= 0) { - // Create a new device object to ensure reactivity - location.cw_devices[deviceIndex] = { - ...location.cw_devices[deviceIndex], - latestData: newData - }; - // Trigger reactivity by reassigning the array - location.cw_devices = [...location.cw_devices]; - break; - } - } - } - // Trigger reactivity by reassigning the locations array - locations = [...locations]; - } -} - -// Export the store functions and state -export function getLocationsStore() { - return { - // State - get locations() { - return locations; - }, - get selectedLocationId() { - return selectedLocationId; - }, - get selectedLocation() { - return selectedLocation; - }, - get loadingLocations() { - return loadingLocations; - }, - get loadingDevices() { - return loadingDevices; - }, - get locationError() { - return locationError; - }, - get deviceError() { - return deviceError; - }, - get devices() { - return devices; - }, - - // Methods - fetchLocations, - selectLocation, - loadDevicesForLocation, - loadAllDevices, - refreshDevicesForLocation, - updateLocationDevices, - updateSingleDevice - }; -} diff --git a/src/lib/stores/SidebarStore.svelte.ts b/src/lib/stores/SidebarStore.svelte.ts deleted file mode 100644 index 92844f12..00000000 --- a/src/lib/stores/SidebarStore.svelte.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { browser } from '$app/environment'; - -/** - * Global sidebar state management - */ -export class SidebarStore { - // Sidebar open/closed state - isOpen = $state(browser ? this.getInitialState() : false); - - // Small icon mode (collapsed but still visible) - isSmallIconMode = $state(browser ? this.getInitialSmallIconMode() : false); - - private getInitialSmallIconMode(): boolean { - // Check if user has a saved preference - const savedMode = localStorage.getItem('sidebar_small_icon_mode'); - if (savedMode !== null) { - return savedMode === 'true'; - } - - // Default to small icon mode on desktop if sidebar is not open - const isMobile = window.innerWidth < 1024; - const savedOpen = localStorage.getItem('sidebar_open'); - - if (!isMobile && (savedOpen === null || savedOpen === 'false')) { - return true; // Default to small icon mode on desktop - } - - return false; - } - - private getInitialState(): boolean { - // Check if we're on mobile vs desktop (align with CSS breakpoints) - const isMobile = window.innerWidth < 1024; // lg breakpoint to match CSS - - // For mobile: closed by default - // For desktop: small icon mode by default (unless user has preference) - const defaultState = !isMobile; - - // Check if user has a saved preference - const savedState = localStorage.getItem('sidebar_open'); - if (savedState !== null) { - return savedState === 'true'; - } - - return defaultState; - } - - toggle() { - this.isOpen = !this.isOpen; - if (browser) { - localStorage.setItem('sidebar_open', this.isOpen.toString()); - } - } - - open() { - this.isOpen = true; - if (browser) { - localStorage.setItem('sidebar_open', 'true'); - } - } - - close() { - this.isOpen = false; - if (browser) { - localStorage.setItem('sidebar_open', 'false'); - } - } - - toggleSmallIconMode() { - this.isSmallIconMode = !this.isSmallIconMode; - if (browser) { - localStorage.setItem('sidebar_small_icon_mode', this.isSmallIconMode.toString()); - } - } - - // Reactive getter for checking if sidebar should be visible - get shouldShowSidebar() { - return this.isOpen || this.isSmallIconMode; - } - - // Initialize responsive behavior - initializeResponsive() { - if (!browser) return () => {}; - - const handleResize = () => { - const isMobile = window.innerWidth < 1024; // Align with CSS breakpoint - - // Auto-close on mobile, enable small icon mode on desktop if no user preference - if (isMobile && this.isOpen && !localStorage.getItem('sidebar_open')) { - this.close(); - } else if ( - !isMobile && - !this.isOpen && - !this.isSmallIconMode && - !localStorage.getItem('sidebar_open') - ) { - // On desktop, default to small icon mode - this.isSmallIconMode = true; - } - }; - - window.addEventListener('resize', handleResize); - - // Initial check - handleResize(); - - // Return cleanup function - return () => { - window.removeEventListener('resize', handleResize); - }; - } -} - -// Global instance -export const sidebarStore = new SidebarStore(); diff --git a/src/lib/stores/loadingStore.ts b/src/lib/stores/loadingStore.ts deleted file mode 100644 index d2d8d4cf..00000000 --- a/src/lib/stores/loadingStore.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { writable } from 'svelte/store'; - -// Global loading state store -export const globalLoading = writable(false); - -// Helper functions -export function startLoading() { - globalLoading.set(true); -} - -export function stopLoading() { - globalLoading.set(false); -} diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts deleted file mode 100644 index 52736228..00000000 --- a/src/lib/stores/theme.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Central theme store: handles 'light' | 'dark' | 'system' modes -// Ensures user preference overrides OS unless mode === 'system' -import { writable, type Writable } from 'svelte/store'; - -export type ThemeMode = 'light' | 'dark' | 'system'; -export interface ThemeState { - mode: ThemeMode; // user selected - effective: 'light' | 'dark'; // applied - system: 'light' | 'dark'; - initialized: boolean; -} - -const STORAGE_KEY = 'theme.mode'; - -function getSystemPref(): 'light' | 'dark' { - if (typeof window === 'undefined') return 'light'; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -} - -function deriveEffective(mode: ThemeMode, system: 'light' | 'dark'): 'light' | 'dark' { - if (mode === 'system') return system; - return mode; -} - -const initialSystem = getSystemPref(); -let initialMode: ThemeMode = 'system'; -if (typeof window !== 'undefined') { - const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null; - if (saved === 'light' || saved === 'dark' || saved === 'system') initialMode = saved; -} - -const initial: ThemeState = { - mode: initialMode, - system: initialSystem, - effective: deriveEffective(initialMode, initialSystem), - initialized: false -}; - -export const themeStore: Writable = writable(initial); - -function applyDOMTheme(theme: 'light' | 'dark', mode: ThemeMode, system: 'light' | 'dark') { - if (typeof document === 'undefined') return; - const root = document.documentElement; - const body = document.body; - - // Apply/remove Tailwind dark class consistently on both html and body - const targets = [root, body].filter((el): el is HTMLElement => Boolean(el)); - targets.forEach((el) => { - if (theme === 'dark') el.classList.add('dark'); - else el.classList.remove('dark'); - }); - - // Data attributes for downstream CSS hooks / debugging - root.dataset.theme = theme; - root.dataset.mode = mode; // user selected value (light|dark|system) - root.dataset.system = system; // current system preference - if (body) { - body.dataset.theme = theme; - } - const explicit = mode !== 'system'; - if (explicit) root.dataset.explicit = 'true'; - else delete root.dataset.explicit; - - // Enforce browser UI (form controls, scrollbars) color scheme so it matches user intent - // This prevents a dark UA style leaking when user forces light while OS is dark. - root.style.colorScheme = theme; - - // Add a helper class only when the user explicitly forces light while the OS is dark. - // This lets us write targeted override rules inside @media(prefers-color-scheme: dark) blocks. - if (mode !== 'system' && mode === 'light' && system === 'dark') { - root.classList.add('force-light'); - } else { - root.classList.remove('force-light'); - } -} - -export function setThemeMode(mode: ThemeMode) { - themeStore.update((s) => { - const system = getSystemPref(); - const effective = deriveEffective(mode, system); - if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, mode); - applyDOMTheme(effective, mode, system); - return { ...s, mode, system, effective }; - }); -} - -export function toggleExplicitLightDark() { - themeStore.update((s) => { - // If system, move to explicit opposite of current effective - let newMode: ThemeMode; - if (s.mode === 'system') newMode = s.effective === 'dark' ? 'light' : 'dark'; - else newMode = s.mode === 'dark' ? 'light' : 'dark'; - if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, newMode); - const system = getSystemPref(); - const effective = deriveEffective(newMode, system); - applyDOMTheme(effective, newMode, system); - return { ...s, mode: newMode, system, effective }; - }); -} - -export function initThemeOnce() { - if (typeof window === 'undefined') return; - let already = false; - themeStore.update((s) => { - if (s.initialized) { - already = true; - return s; - } - const system = getSystemPref(); - const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null; - const mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : s.mode; - const effective = deriveEffective(mode, system); - applyDOMTheme(effective, mode, system); - return { ...s, mode, system, effective, initialized: true }; - }); - if (already) return; - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - mq.addEventListener('change', (e) => { - themeStore.update((s) => { - if (s.mode !== 'system') { - return s; - } - const system = e.matches ? 'dark' : 'light'; - const effective = deriveEffective(s.mode, system); - applyDOMTheme(effective, s.mode, system); - return { ...s, system, effective }; - }); - }); -} - -// Initialize immediately on client -if (typeof window !== 'undefined') { - initThemeOnce(); -} diff --git a/src/lib/stores/toast.svelte.ts b/src/lib/stores/toast.svelte.ts deleted file mode 100644 index 36d25ba0..00000000 --- a/src/lib/stores/toast.svelte.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'neutral'; - -export interface Toast { - id: string; - type: ToastType; - message: string; - dismissible?: boolean; - timeout?: number; -} - -// Svelte 5 runes: use $state for the toast array (non-exported) -let toasts = $state([]); - -// Export a getter function to access the toasts array -export function getToasts() { - return toasts; -} - -function generateId() { - return Math.random().toString(36).substring(2, 9); -} - -export function add(toast: Omit) { - const id = generateId(); - const defaults = { - dismissible: true, - timeout: 5000 - }; - const newToast: Toast = { ...defaults, ...toast, id }; - // Use push instead of reassignment - toasts.push(newToast); - if (newToast.timeout && typeof window !== 'undefined') { - setTimeout(() => remove(id), newToast.timeout); - } - return id; -} - -export function remove(id: string) { - // Find and remove the toast with the given id - const index = toasts.findIndex((t) => t.id === id); - if (index !== -1) { - toasts.splice(index, 1); - } -} - -export function clear() { - // Clear the array without reassignment - toasts.splice(0, toasts.length); -} - -export function success(message: string, options = {}) { - return add({ type: 'success', message, ...options }); -} -export function error(message: string, options = {}) { - return add({ type: 'error', message, ...options }); -} -export function warning(message: string, options = {}) { - return add({ type: 'warning', message, ...options }); -} -export function info(message: string, options = {}) { - return add({ type: 'info', message, ...options }); -} -export function neutral(message: string, options = {}) { - return add({ type: 'neutral', message, ...options }); -} diff --git a/src/lib/supabase.server.ts b/src/lib/supabase.server.ts new file mode 100644 index 00000000..37ce57b1 --- /dev/null +++ b/src/lib/supabase.server.ts @@ -0,0 +1,23 @@ +import { createClient } from '@supabase/supabase-js'; +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; + +/** + * Server-side Supabase client using the public anon key. + * Safe to use in `+page.server.ts` / `+server.ts` — the anon key + * is subject to RLS and does not bypass security policies. + */ +export function getSupabaseClient() { + const publicSupabaseUrl = PUBLIC_SUPABASE_URL; + const publicSupabaseAnonKey = PUBLIC_SUPABASE_ANON_KEY; + + if (!publicSupabaseUrl || !publicSupabaseAnonKey) { + throw new Error('Supabase public environment variables are missing.'); + } + + return createClient(publicSupabaseUrl, publicSupabaseAnonKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }); +} diff --git a/src/lib/tests/CSVExportValidation.test.ts b/src/lib/tests/CSVExportValidation.test.ts deleted file mode 100644 index eb21dfb3..00000000 --- a/src/lib/tests/CSVExportValidation.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { DateTime } from 'luxon'; - -/** - * CSV EXPORT VALIDATION TESTS - * - * These tests validate that CSV exports show realistic traffic patterns - * and proper timezone formatting. This prevents regression of the - * "very very backwards" traffic pattern bug. - */ - -describe('CSV Export Validation Tests', () => { - const baseUrl = 'http://localhost:5173'; - const devEui = '110110145241600107'; - const timezone = 'Asia/Tokyo'; - - // Helper to make authenticated requests (you may need to adjust based on your auth) - async function fetchCSV(startDate: string, endDate: string, timezone: string) { - const url = `${baseUrl}/api/devices/${devEui}/csv?startDate=${startDate}&endDate=${endDate}&timezone=${timezone}`; - - try { - const response = await fetch(url, { - headers: { - Accept: 'text/csv' - // Add authentication headers if needed - // 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.text(); - } catch (error) { - // If server is not running, skip the test - if (error instanceof Error && error.message.includes('ECONNREFUSED')) { - console.warn('⚠️ Development server not running, skipping CSV API test'); - return null; - } - throw error; - } - } - - describe('CSV Format and Timezone Validation', () => { - it('should generate CSV with proper timezone formatting for August 1st', async () => { - // Arrange - const startDate = '2025-08-01'; - const endDate = '2025-08-01'; - - // Act - const csvData = await fetchCSV(startDate, endDate, timezone); - - // Skip test if server not running - if (csvData === null) return; - - // Assert - expect(csvData).toBeDefined(); - expect(typeof csvData).toBe('string'); - - // Should contain CSV headers - expect(csvData).toContain('traffic_hour'); - expect(csvData).toContain('people_count'); - expect(csvData).toContain('car_count'); - - // Should contain August 1st data - expect(csvData).toContain('2025-08-01'); - - // Should NOT contain the backwards pattern indicators - // (i.e., should not have high traffic at very early morning hours) - const lines = csvData.split('\n'); - const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header - - if (dataLines.length > 0) { - // Parse a few data rows to check timezone formatting - const sampleRow = dataLines[0].split(','); - const timestampColumn = sampleRow[0]; // Assuming first column is timestamp - - // Should be in proper ISO format with timezone - expect(timestampColumn).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - - console.log(`✅ VERIFIED: CSV contains properly formatted timestamps: ${timestampColumn}`); - } - }, 10000); - - it('should show realistic traffic patterns in CSV export', async () => { - // Arrange - Get a sample day - const startDate = '2025-08-15'; - const endDate = '2025-08-15'; - - // Act - const csvData = await fetchCSV(startDate, endDate, timezone); - - // Skip test if server not running - if (csvData === null) return; - - // Assert - expect(csvData).toBeDefined(); - - const lines = csvData.split('\n'); - const headerLine = lines[0]; - const dataLines = lines.slice(1).filter((line) => line.trim()); - - if (dataLines.length === 0) { - console.warn('⚠️ No data found for test date, skipping pattern validation'); - return; - } - - // Find column indices - const headers = headerLine.split(','); - const timestampIndex = headers.findIndex( - (h) => h.includes('traffic_hour') || h.includes('timestamp') - ); - const peopleIndex = headers.findIndex((h) => h.includes('people_count')); - const carIndex = headers.findIndex((h) => h.includes('car_count')); - - expect(timestampIndex).toBeGreaterThanOrEqual(0); - expect(peopleIndex).toBeGreaterThanOrEqual(0); - expect(carIndex).toBeGreaterThanOrEqual(0); - - // Parse data and categorize by hour - const hourlyData: { [hour: number]: { people: number; cars: number; count: number } } = {}; - - dataLines.forEach((line) => { - const columns = line.split(','); - const timestamp = columns[timestampIndex]; - const people = parseInt(columns[peopleIndex]) || 0; - const cars = parseInt(columns[carIndex]) || 0; - - // Parse timestamp to get hour in Tokyo timezone - const dt = DateTime.fromISO(timestamp).setZone(timezone); - const hour = dt.hour; - - if (!hourlyData[hour]) { - hourlyData[hour] = { people: 0, cars: 0, count: 0 }; - } - - hourlyData[hour].people += people; - hourlyData[hour].cars += cars; - hourlyData[hour].count += 1; - }); - - // Calculate averages for night vs day - let nightTraffic = 0, - dayTraffic = 0; - let nightCount = 0, - dayCount = 0; - - Object.entries(hourlyData).forEach(([hourStr, data]) => { - const hour = parseInt(hourStr); - const avgTraffic = (data.people + data.cars) / data.count; - - if (hour >= 0 && hour <= 5) { - // Night hours - nightTraffic += avgTraffic; - nightCount++; - } else if (hour >= 9 && hour <= 17) { - // Day hours - dayTraffic += avgTraffic; - dayCount++; - } - }); - - if (nightCount > 0 && dayCount > 0) { - const avgNightTraffic = nightTraffic / nightCount; - const avgDayTraffic = dayTraffic / dayCount; - - // Realistic pattern: day should have more traffic than night - expect(avgDayTraffic).toBeGreaterThanOrEqual(avgNightTraffic); - - console.log( - `✅ VERIFIED: Realistic CSV traffic pattern - Avg Day: ${avgDayTraffic.toFixed(2)}, Avg Night: ${avgNightTraffic.toFixed(2)}` - ); - } - }, 15000); - - it('should maintain consistent timezone formatting across full month CSV', async () => { - // Arrange - Test full August month - const startDate = '2025-08-01'; - const endDate = '2025-08-31'; - - // Act - const csvData = await fetchCSV(startDate, endDate, timezone); - - // Skip test if server not running - if (csvData === null) return; - - // Assert - expect(csvData).toBeDefined(); - - const lines = csvData.split('\n'); - const dataLines = lines.slice(1).filter((line) => line.trim()); - - if (dataLines.length === 0) { - console.warn('⚠️ No data found for full month, skipping consistency test'); - return; - } - - // Check that we have a reasonable amount of data - expect(dataLines.length).toBeGreaterThan(500); // Should be ~744 for full month - - // Sample some timestamps to verify consistency - const sampleIndices = [ - 0, - Math.floor(dataLines.length / 4), - Math.floor(dataLines.length / 2), - dataLines.length - 1 - ]; - - sampleIndices.forEach((index) => { - if (index < dataLines.length) { - const line = dataLines[index]; - const timestamp = line.split(',')[0]; - - // Should be properly formatted ISO timestamp - expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - - // Should be within August 2025 range - expect(timestamp).toMatch(/^2025-08/); - } - }); - - console.log( - `✅ VERIFIED: Consistent timezone formatting across ${dataLines.length} CSV records` - ); - }, 20000); - }); - - describe('CSV Content Validation', () => { - it('should include the critical record (ID 35976) in August 1st CSV', async () => { - // Arrange - const startDate = '2025-08-01'; - const endDate = '2025-08-01'; - - // Act - const csvData = await fetchCSV(startDate, endDate, timezone); - - // Skip test if server not running - if (csvData === null) return; - - // Assert - expect(csvData).toBeDefined(); - - // Should contain August 1st midnight data - // The exact time representation might vary based on CSV formatting, - // but it should include the midnight hour - expect(csvData).toContain('2025-08-01'); - - // Should have multiple records for the day (24 hours worth) - const lines = csvData.split('\n'); - const dataLines = lines.slice(1).filter((line) => line.trim()); - expect(dataLines.length).toBeGreaterThan(10); // At least some data for the day - - console.log(`✅ VERIFIED: August 1st CSV contains ${dataLines.length} records`); - }, 10000); - - it('should validate CSV structure and completeness', async () => { - // Arrange - const startDate = '2025-08-10'; // Use a mid-month date for stability - const endDate = '2025-08-10'; - - // Act - const csvData = await fetchCSV(startDate, endDate, timezone); - - // Skip test if server not running - if (csvData === null) return; - - // Assert - const lines = csvData.split('\n'); - expect(lines.length).toBeGreaterThan(1); // Header + at least some data - - const headerLine = lines[0]; - const requiredColumns = [ - 'traffic_hour', - 'people_count', - 'car_count', - 'truck_count', - 'bicycle_count' - ]; - - requiredColumns.forEach((column) => { - expect(headerLine.toLowerCase()).toContain(column.toLowerCase()); - }); - - // Validate data rows have same number of columns as header - const headerColumnCount = headerLine.split(',').length; - const dataLines = lines.slice(1).filter((line) => line.trim()); - - dataLines.slice(0, 5).forEach((line) => { - // Check first few rows - const columnCount = line.split(',').length; - expect(columnCount).toBe(headerColumnCount); - }); - - console.log(`✅ VERIFIED: CSV structure is valid with ${headerColumnCount} columns`); - }, 10000); - }); -}); - -/** - * MOCK CSV TESTS (for when server is not running) - * - * These tests can run without a live server and validate the CSV generation logic. - */ -describe('CSV Generation Logic Tests', () => { - it('should format timestamps correctly for Tokyo timezone', () => { - // Arrange - const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC - const timezone = 'Asia/Tokyo'; - - // Act - Convert UTC to Tokyo time (same logic as CSV generation) - const dt = DateTime.fromISO(utcTimestamp, { zone: 'UTC' }); - const tokyoTime = dt.setZone(timezone); - const formattedTime = tokyoTime.toISO(); - - // Assert - Should be August 1st midnight Tokyo - expect(formattedTime).toContain('2025-08-01T00:00:00'); - expect(formattedTime).toContain('+09:00'); // Tokyo timezone offset - }); - - it('should validate traffic_hour field handling for CSV', () => { - // Arrange - Sample traffic data record - const trafficRecord = { - id: 35976, - traffic_hour: '2025-07-31T15:00:00+00:00', - people_count: 0, - car_count: 6, - truck_count: 1, - bicycle_count: 0, - bus_count: 0 - }; - - // Act - Convert to Tokyo timezone for CSV - const dt = DateTime.fromISO(trafficRecord.traffic_hour); - const tokyoTime = dt.setZone('Asia/Tokyo'); - - // Assert - expect(tokyoTime.year).toBe(2025); - expect(tokyoTime.month).toBe(8); // August - expect(tokyoTime.day).toBe(1); - expect(tokyoTime.hour).toBe(0); // Midnight - - // Should be formatted for CSV - const csvTimestamp = tokyoTime.toISO(); - expect(csvTimestamp).toBeDefined(); - expect(csvTimestamp).toContain('2025-08-01T00:00:00'); - }); -}); diff --git a/src/lib/tests/DeviceDataService.timezone.test.ts b/src/lib/tests/DeviceDataService.timezone.test.ts deleted file mode 100644 index 577d46ef..00000000 --- a/src/lib/tests/DeviceDataService.timezone.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { DateTime } from 'luxon'; -import { DeviceDataService } from '../services/DeviceDataService'; -import type { SupabaseClient } from '@supabase/supabase-js'; - -/** - * CRITICAL TIMEZONE CONVERSION TESTS - * - * These tests ensure that the timezone conversion logic that fixes the - * "very very backwards" traffic patterns continues to work correctly. - * - * Key requirement: August 1st midnight Tokyo should convert to July 31st 3PM UTC - * This specific conversion was verified to return record ID 35976 in production. - */ - -describe('DeviceDataService - Critical Timezone Conversions', () => { - let deviceDataService: DeviceDataService; - let mockSupabase: SupabaseClient; - - beforeEach(() => { - // Mock Supabase client - mockSupabase = { - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { - cw_device_type: { data_table_v2: 'cw_traffic2' } - }, - error: null - }) - }) - }) - }) - } as any; - - deviceDataService = new DeviceDataService(mockSupabase); - }); - - describe('convertUserTimezoneToUTC', () => { - it('should handle UTC timezone (no conversion)', () => { - // Arrange - const date = new Date('2025-08-01T00:00:00.000Z'); - const timezone = 'UTC'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); - - // Assert - expect(result).toEqual(date); - }); - - it('should convert August 1st midnight Tokyo to July 31st 3PM UTC (CRITICAL TEST)', () => { - // Arrange - This is the exact conversion that was verified to work - const tokyoMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); // Month is 0-indexed (7 = August) - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoMidnight, timezone); - - // Assert - Must convert to July 31st 15:00 UTC (3PM) - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(6); // 0-indexed (6 = July) - expect(result.getUTCDate()).toBe(31); - expect(result.getUTCHours()).toBe(15); // 3 PM UTC - expect(result.getUTCMinutes()).toBe(0); - expect(result.getUTCSeconds()).toBe(0); - - // Additional verification using Luxon - const luxonResult = DateTime.fromJSDate(result, { zone: 'UTC' }); - expect(luxonResult.toISO()).toBe('2025-07-31T15:00:00.000Z'); - }); - - it('should convert August 31st 11:59 PM Tokyo correctly', () => { - // Arrange - End of August in Tokyo - const tokyoEndOfMonth = new Date(2025, 7, 31, 23, 59, 59, 0); - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoEndOfMonth, timezone); - - // Assert - Should convert to August 31st 14:59 UTC - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(7); // 0-indexed (7 = August) - expect(result.getUTCDate()).toBe(31); - expect(result.getUTCHours()).toBe(14); - expect(result.getUTCMinutes()).toBe(59); - }); - - it('should handle New York timezone correctly', () => { - // Arrange - const nyMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); - const timezone = 'America/New_York'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(nyMidnight, timezone); - - // Assert - August 1st midnight EST/EDT should convert to 4AM or 5AM UTC (depending on DST) - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(7); // Same month - expect(result.getUTCDate()).toBe(1); // Same day - expect(result.getUTCHours()).toBeGreaterThanOrEqual(4); - expect(result.getUTCHours()).toBeLessThanOrEqual(5); - }); - - it('should handle DST transitions correctly', () => { - // Arrange - Test during a known DST period - const date = new Date(2025, 5, 15, 12, 0, 0, 0); // June 15th noon - const timezone = 'America/New_York'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); - - // Assert - Should handle DST correctly - expect(result).toBeInstanceOf(Date); - expect(result.getUTCHours()).toBeGreaterThan(12); // Should be afternoon UTC - }); - - it('should preserve exact time components during conversion', () => { - // Arrange - const date = new Date(2025, 7, 15, 14, 30, 45, 123); // Specific time with milliseconds - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); - - // Assert - Minutes, seconds should be preserved (hours will shift) - expect(result.getUTCMinutes()).toBe(30); - expect(result.getUTCSeconds()).toBe(45); - }); - }); - - describe('convertUTCToUserTimezone', () => { - it('should handle UTC timezone (no conversion)', () => { - // Arrange - const utcTimestamp = '2025-08-01T15:00:00.000Z'; - const timezone = 'UTC'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); - - // Assert - expect(result).toBe(utcTimestamp); - }); - - it('should convert UTC back to Tokyo timezone correctly (CRITICAL TEST)', () => { - // Arrange - This is the reverse of our critical conversion - const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); - - // Assert - Should convert back to August 1st midnight Tokyo - const resultDt = DateTime.fromISO(result); - expect(resultDt.year).toBe(2025); - expect(resultDt.month).toBe(8); // August - expect(resultDt.day).toBe(1); - expect(resultDt.hour).toBe(0); // Midnight - expect(resultDt.minute).toBe(0); - }); - - it('should handle SQL timestamp format', () => { - // Arrange - Test SQL format (common in Supabase) - const sqlTimestamp = '2025-07-31 15:00:00+00'; - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone(sqlTimestamp, timezone); - - // Assert - Should parse and convert correctly - expect(result).toContain('2025-08-01'); - expect(result).toContain('00:00:00'); - }); - - it('should handle invalid timestamps gracefully', () => { - // Arrange - const invalidTimestamp = 'invalid-date-string'; - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone( - invalidTimestamp, - timezone - ); - - // Assert - Should return original string when parsing fails - expect(result).toBe(invalidTimestamp); - }); - - it('should preserve timezone offset in output', () => { - // Arrange - const utcTimestamp = '2025-08-01T12:00:00.000Z'; - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); - - // Assert - Should include timezone offset - expect(result).toContain('+09:00'); // Tokyo is UTC+9 - }); - }); - - describe('Round-trip conversion accuracy', () => { - it('should maintain accuracy through round-trip conversions', () => { - // Arrange - const originalDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight - const timezone = 'Asia/Tokyo'; - - // Act - Convert to UTC and back - const utcDate = (deviceDataService as any).convertUserTimezoneToUTC(originalDate, timezone); - const backToTimezone = (deviceDataService as any).convertUTCToUserTimezone( - utcDate.toISOString(), - timezone - ); - - // Assert - Should get back to original time - const finalDt = DateTime.fromISO(backToTimezone); - expect(finalDt.year).toBe(2025); - expect(finalDt.month).toBe(8); // August - expect(finalDt.day).toBe(1); - expect(finalDt.hour).toBe(0); // Midnight - }); - - it('should handle Tokyo timezone round-trips accurately (CRITICAL TEST)', () => { - // Arrange - Test the specific timezone we verified works - const testDates = [ - new Date(2025, 7, 1, 0, 0, 0, 0), // August 1st midnight - new Date(2025, 7, 15, 12, 0, 0, 0), // August 15th noon - new Date(2025, 7, 31, 23, 59, 59, 0) // August 31st end of day - ]; - const timezone = 'Asia/Tokyo'; - - testDates.forEach((testDate) => { - // Act - const utcDate = (deviceDataService as any).convertUserTimezoneToUTC(testDate, timezone); - const backToTimezone = (deviceDataService as any).convertUTCToUserTimezone( - utcDate.toISOString(), - timezone - ); - - // Assert - const finalDt = DateTime.fromISO(backToTimezone); - expect(finalDt.year).toBe(testDate.getFullYear()); - expect(finalDt.month).toBe(testDate.getMonth() + 1); // Luxon uses 1-based months - expect(finalDt.day).toBe(testDate.getDate()); - expect(finalDt.hour).toBe(testDate.getHours()); - }); - }); - }); - - describe('Edge cases and error handling', () => { - it('should handle leap year dates correctly', () => { - // Arrange - February 29th, 2024 (leap year) - const leapDate = new Date(2024, 1, 29, 12, 0, 0, 0); - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(leapDate, timezone); - - // Assert - expect(result).toBeInstanceOf(Date); - expect(result.getUTCMonth()).toBe(1); // February - expect(result.getUTCDate()).toBe(29); - }); - - it('should handle year boundaries correctly', () => { - // Arrange - New Year's Eve 11:59 PM Tokyo - const newYearEve = new Date(2024, 11, 31, 23, 59, 59, 0); - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(newYearEve, timezone); - - // Assert - Should convert to earlier in the day UTC (still Dec 31st) - expect(result.getUTCFullYear()).toBe(2024); - expect(result.getUTCMonth()).toBe(11); // December - expect(result.getUTCDate()).toBe(31); - }); - - it('should handle various timezone formats', () => { - // Arrange - const date = new Date(2025, 7, 1, 12, 0, 0, 0); - const timezones = [ - 'Asia/Tokyo', - 'America/New_York', - 'Europe/London', - 'UTC', - 'Pacific/Auckland' - ]; - - timezones.forEach((timezone) => { - // Act & Assert - Should not throw errors - expect(() => { - (deviceDataService as any).convertUserTimezoneToUTC(date, timezone); - }).not.toThrow(); - }); - }); - }); -}); diff --git a/src/lib/tests/NonTrafficDeviceTimezone.test.ts b/src/lib/tests/NonTrafficDeviceTimezone.test.ts deleted file mode 100644 index 628f4e36..00000000 --- a/src/lib/tests/NonTrafficDeviceTimezone.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { DateTime } from 'luxon'; -import { DeviceDataService } from '../services/DeviceDataService'; -import type { SupabaseClient } from '@supabase/supabase-js'; - -/** - * CRITICAL NON-TRAFFIC DEVICE TIMEZONE CONVERSION TESTS - * - * These tests ensure that timezone conversion works correctly for all non-traffic - * device types that use the `created_at` field instead of `traffic_hour`. - * - * Device types covered: - * - Air sensors (cw_air_data) - temperature, humidity, pressure, CO2, etc. - * - Soil sensors (cw_soil_data) - moisture, pH, EC, temperature - * - Water sensors (cw_water_data) - depth, pressure, temperature - * - Pulse meters (cw_pulse_meters) - water flow counting - * - Relay data (cw_relay_data) - relay states - * - Water meter uplinks (cw_watermeter_uplinks) - flow data - * - * Key requirement: created_at timestamps should follow the same timezone - * conversion logic as traffic_hour but use different field names. - */ - -describe('DeviceDataService - Non-Traffic Device Timezone Conversions', () => { - let deviceDataService: DeviceDataService; - let mockSupabase: SupabaseClient; - - // Mock device types for different sensors - const mockDeviceTypes = { - airSensor: { - cw_device_type: { - data_table_v2: 'cw_air_data', - name: 'Air Quality Sensor' - } - }, - soilSensor: { - cw_device_type: { - data_table_v2: 'cw_soil_data', - name: 'Soil Moisture Sensor' - } - }, - waterSensor: { - cw_device_type: { - data_table_v2: 'cw_water_data', - name: 'Water Level Sensor' - } - }, - pulseMeter: { - cw_device_type: { - data_table_v2: 'cw_pulse_meters', - name: 'Water Flow Meter' - } - }, - trafficCamera: { - cw_device_type: { - data_table_v2: 'cw_traffic2', - name: 'Traffic Camera' - } - } - }; - - beforeEach(() => { - // Mock Supabase client with device type responses - mockSupabase = { - from: vi.fn().mockImplementation((tableName) => { - if (tableName === 'cw_devices') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockImplementation(() => { - // Return different device types based on test context - const devEui = expect.getState().currentTestName?.includes('air') - ? 'air-device' - : expect.getState().currentTestName?.includes('soil') - ? 'soil-device' - : expect.getState().currentTestName?.includes('water') - ? 'water-device' - : expect.getState().currentTestName?.includes('pulse') - ? 'pulse-device' - : 'air-device'; // default - - const deviceType = devEui.includes('air') - ? mockDeviceTypes.airSensor - : devEui.includes('soil') - ? mockDeviceTypes.soilSensor - : devEui.includes('water') - ? mockDeviceTypes.waterSensor - : devEui.includes('pulse') - ? mockDeviceTypes.pulseMeter - : mockDeviceTypes.airSensor; - - return Promise.resolve({ - data: deviceType, - error: null - }); - }) - }) - }) - }; - } - - // Mock data queries - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - gte: vi.fn().mockReturnValue({ - lte: vi.fn().mockReturnValue({ - order: vi.fn().mockReturnValue({ - // Mock successful data response - data: [], - error: null - }) - }) - }) - }) - }) - }; - }) - } as any; - - deviceDataService = new DeviceDataService(mockSupabase); - }); - - describe('Field Selection Logic', () => { - // Helper function to determine timestamp field (matches DeviceDataService logic) - const getTimestampField = (tableName: string) => { - return tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - }; - - it('should use created_at for air sensor devices (NOT traffic_hour)', () => { - // This test verifies the conditional logic in DeviceDataService - const tableName = 'cw_air_data'; - const timestampField = getTimestampField(tableName); - - expect(timestampField).toBe('created_at'); - expect(timestampField).not.toBe('traffic_hour'); - }); - - it('should use created_at for soil sensor devices', () => { - const tableName = 'cw_soil_data'; - const timestampField = getTimestampField(tableName); - - expect(timestampField).toBe('created_at'); - }); - - it('should use created_at for water sensor devices', () => { - const tableName = 'cw_water_data'; - const timestampField = getTimestampField(tableName); - - expect(timestampField).toBe('created_at'); - }); - - it('should use created_at for pulse meter devices', () => { - const tableName = 'cw_pulse_meters'; - const timestampField = getTimestampField(tableName); - - expect(timestampField).toBe('created_at'); - }); - - it('should use traffic_hour ONLY for traffic camera devices', () => { - const tableName = 'cw_traffic2'; - const timestampField = getTimestampField(tableName); - - expect(timestampField).toBe('traffic_hour'); - expect(timestampField).not.toBe('created_at'); - }); - }); - - describe('Timezone Conversion with created_at Field', () => { - it('should convert August 1st midnight Tokyo to July 31st 3PM UTC for air sensors (CRITICAL TEST)', () => { - // Arrange - Same critical test as traffic, but for non-traffic devices - const tokyoMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight - const timezone = 'Asia/Tokyo'; - - // Act - Use the same timezone conversion method - const result = (deviceDataService as any).convertUserTimezoneToUTC(tokyoMidnight, timezone); - - // Assert - Must convert to July 31st 15:00 UTC (same as traffic) - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(6); // 0-indexed (6 = July) - expect(result.getUTCDate()).toBe(31); - expect(result.getUTCHours()).toBe(15); // 3 PM UTC - expect(result.getUTCMinutes()).toBe(0); - expect(result.getUTCSeconds()).toBe(0); - - // Verification using Luxon - const luxonResult = DateTime.fromJSDate(result, { zone: 'UTC' }); - expect(luxonResult.toISO()).toBe('2025-07-31T15:00:00.000Z'); - }); - - it('should convert UTC back to Tokyo timezone for soil sensors (CRITICAL TEST)', () => { - // Arrange - Reverse conversion test - const utcTimestamp = '2025-07-31T15:00:00.000Z'; // July 31st 3PM UTC - const timezone = 'Asia/Tokyo'; - - // Act - const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); - - // Assert - Should convert back to August 1st midnight Tokyo - const resultDt = DateTime.fromISO(result); - expect(resultDt.year).toBe(2025); - expect(resultDt.month).toBe(8); // August - expect(resultDt.day).toBe(1); - expect(resultDt.hour).toBe(0); // Midnight - expect(resultDt.minute).toBe(0); - }); - - it('should handle different timezones for water sensors', () => { - // Arrange - const newYorkMidnight = new Date(2025, 7, 1, 0, 0, 0, 0); - const timezone = 'America/New_York'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(newYorkMidnight, timezone); - - // Assert - August 1st midnight EST/EDT should convert to 4AM or 5AM UTC - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(7); // Same month - expect(result.getUTCDate()).toBe(1); // Same day - expect(result.getUTCHours()).toBeGreaterThanOrEqual(4); - expect(result.getUTCHours()).toBeLessThanOrEqual(5); - }); - - it('should handle Europe timezone for pulse meters', () => { - // Arrange - const londonNoon = new Date(2025, 7, 1, 12, 0, 0, 0); - const timezone = 'Europe/London'; - - // Act - const result = (deviceDataService as any).convertUserTimezoneToUTC(londonNoon, timezone); - - // Assert - expect(result.getUTCFullYear()).toBe(2025); - expect(result.getUTCMonth()).toBe(7); // August - expect(result.getUTCDate()).toBe(1); - // London is UTC+0 or UTC+1 depending on DST - expect(result.getUTCHours()).toBeGreaterThanOrEqual(11); - expect(result.getUTCHours()).toBeLessThanOrEqual(12); - }); - }); - - describe('Device Type Conditional Logic Validation', () => { - it('should correctly identify non-traffic devices and use created_at', () => { - // Test all non-traffic device tables - const nonTrafficTables = [ - 'cw_air_data', - 'cw_soil_data', - 'cw_water_data', - 'cw_pulse_meters', - 'cw_relay_data', - 'cw_watermeter_uplinks' - ]; - - nonTrafficTables.forEach((tableName) => { - const timestampField = tableName === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - expect(timestampField).toBe('created_at'); - expect(tableName).not.toBe('cw_traffic2'); - }); - }); - - it('should correctly identify traffic devices and use traffic_hour', () => { - const trafficTable = 'cw_traffic2'; - const timestampField = trafficTable === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - - expect(timestampField).toBe('traffic_hour'); - expect(trafficTable).toBe('cw_traffic2'); - }); - }); - - describe('Air Sensor Specific Tests', () => { - it('should handle air sensor date ranges with created_at field', () => { - // Arrange - const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st - const endDate = new Date(2025, 7, 1, 23, 59, 59, 999); // End of August 1st - const timezone = 'Asia/Tokyo'; - - // Act - Convert dates for database query - const utcStartDate = (deviceDataService as any).convertUserTimezoneToUTC(startDate, timezone); - const utcEndDate = (deviceDataService as any).convertUserTimezoneToUTC(endDate, timezone); - - // Assert - Verify the UTC conversion for database query - expect(utcStartDate.getUTCDate()).toBe(31); // July 31st - expect(utcStartDate.getUTCHours()).toBe(15); // 3 PM UTC - - expect(utcEndDate.getUTCDate()).toBe(1); // August 1st - expect(utcEndDate.getUTCHours()).toBe(14); // 2:59 PM UTC next day - }); - - it('should format air sensor timestamps correctly for CSV export', () => { - // Arrange - Simulate air sensor data timestamp - const utcTimestamp = '2025-07-31T15:00:00+00:00'; // UTC timestamp from database - const timezone = 'Asia/Tokyo'; - - // Act - Convert for CSV display - const result = (deviceDataService as any).convertUTCToUserTimezone(utcTimestamp, timezone); - - // Assert - Should show Tokyo time in CSV - expect(result).toContain('2025-08-01T00:00:00'); - expect(result).toContain('+09:00'); // Tokyo timezone offset - }); - }); - - describe('Soil Sensor Specific Tests', () => { - it('should handle soil sensor moisture readings with proper timestamps', () => { - // Arrange - Typical soil sensor reading time - const readingTime = new Date(2025, 7, 15, 6, 0, 0, 0); // 6 AM reading - const timezone = 'Asia/Tokyo'; - - // Act - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(readingTime, timezone); - - // Assert - 6 AM Tokyo = 9 PM previous day UTC - expect(utcTime.getUTCDate()).toBe(14); // Previous day - expect(utcTime.getUTCHours()).toBe(21); // 9 PM UTC - }); - - it('should round-trip soil sensor timestamps accurately', () => { - // Arrange - const originalTime = new Date(2025, 7, 15, 8, 30, 0, 0); - const timezone = 'Asia/Tokyo'; - - // Act - Convert to UTC and back - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(originalTime, timezone); - const backToTokyo = (deviceDataService as any).convertUTCToUserTimezone( - utcTime.toISOString(), - timezone - ); - - // Assert - const finalDt = DateTime.fromISO(backToTokyo); - expect(finalDt.year).toBe(originalTime.getFullYear()); - expect(finalDt.month).toBe(originalTime.getMonth() + 1); - expect(finalDt.day).toBe(originalTime.getDate()); - expect(finalDt.hour).toBe(originalTime.getHours()); - expect(finalDt.minute).toBe(originalTime.getMinutes()); - }); - }); - - describe('Water Sensor Specific Tests', () => { - it('should handle water level sensor readings across day boundaries', () => { - // Arrange - Late night reading - const lateNightReading = new Date(2025, 7, 1, 23, 45, 0, 0); - const timezone = 'Asia/Tokyo'; - - // Act - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC( - lateNightReading, - timezone - ); - - // Assert - 11:45 PM Tokyo = 2:45 PM UTC same day - expect(utcTime.getUTCDate()).toBe(1); // Same day - expect(utcTime.getUTCHours()).toBe(14); // 2 PM UTC - expect(utcTime.getUTCMinutes()).toBe(45); - }); - }); - - describe('Pulse Meter Specific Tests', () => { - it('should handle pulse meter counts with precise timestamps', () => { - // Arrange - Water flow measurement - const measurementTime = new Date(2025, 7, 15, 14, 15, 30, 0); - const timezone = 'Asia/Tokyo'; - - // Act - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC( - measurementTime, - timezone - ); - - // Assert - 2:15:30 PM Tokyo = 5:15:30 AM UTC - expect(utcTime.getUTCHours()).toBe(5); - expect(utcTime.getUTCMinutes()).toBe(15); - expect(utcTime.getUTCSeconds()).toBe(30); - }); - }); - - describe('Multi-Device Type Consistency', () => { - it('should apply same timezone conversion logic across all non-traffic device types', () => { - // Arrange - const testTime = new Date(2025, 7, 1, 12, 0, 0, 0); // Noon Tokyo - const timezone = 'Asia/Tokyo'; - - const deviceTypes = ['air', 'soil', 'water', 'pulse']; - - deviceTypes.forEach((deviceType) => { - // Act - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(testTime, timezone); - - // Assert - All device types should use same conversion logic - expect(utcTime.getUTCHours()).toBe(3); // Noon Tokyo = 3 AM UTC - expect(utcTime.getUTCDate()).toBe(1); // Same day - }); - }); - - it('should maintain timestamp precision across all device types', () => { - // Arrange - Test with millisecond precision - const preciseTime = new Date(2025, 7, 15, 10, 30, 45, 123); - const timezone = 'Asia/Tokyo'; - - // Act - const utcTime = (deviceDataService as any).convertUserTimezoneToUTC(preciseTime, timezone); - const backToTokyo = (deviceDataService as any).convertUTCToUserTimezone( - utcTime.toISOString(), - timezone - ); - - // Assert - Precision should be maintained - const finalDt = DateTime.fromISO(backToTokyo); - expect(finalDt.minute).toBe(30); - expect(finalDt.second).toBe(45); - // Note: Milliseconds might be lost in ISO conversion, which is acceptable - }); - }); - - describe('Error Handling for Non-Traffic Devices', () => { - it('should handle invalid timestamps gracefully for all device types', () => { - // Arrange - const invalidTimestamp = 'invalid-timestamp'; - const timezone = 'Asia/Tokyo'; - - // Act & Assert - const result = (deviceDataService as any).convertUTCToUserTimezone( - invalidTimestamp, - timezone - ); - expect(result).toBe(invalidTimestamp); // Should return original on error - }); - - it('should handle unknown device types gracefully', () => { - // This test ensures that unknown device types default to created_at - const unknownTable = 'cw_unknown_sensors'; - // Use a function to avoid TypeScript warnings about literal comparisons - const getTimestampField = (table: string) => - table === 'cw_traffic2' ? 'traffic_hour' : 'created_at'; - const timestampField = getTimestampField(unknownTable); - - expect(timestampField).toBe('created_at'); // Safe default - }); - }); -}); diff --git a/src/lib/tests/NonTrafficIntegration.test.ts b/src/lib/tests/NonTrafficIntegration.test.ts deleted file mode 100644 index 9b323ec5..00000000 --- a/src/lib/tests/NonTrafficIntegration.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { createClient } from '@supabase/supabase-js'; -import { DateTime } from 'luxon'; -import type { Database } from '../../../database.types'; - -type DeviceRow = Pick< - Database['public']['Tables']['cw_devices']['Row'], - 'dev_eui' | 'name' | 'type' ->; -type DeviceTypeRow = Pick< - Database['public']['Tables']['cw_device_type']['Row'], - 'id' | 'name' | 'data_table_v2' ->; - -/** - * NON-TRAFFIC DEVICE INTEGRATION TESTS - * - * These tests verify the complete timezone conversion flow for all non-traffic - * device types that use the `created_at` field. This ensures that air sensors, - * soil sensors, water sensors, etc. all follow the same timezone logic as - * traffic cameras but use the correct timestamp field. - * - * Device types tested: - * - Air quality sensors (cw_air_data) - * - Soil moisture sensors (cw_soil_data) - * - Water level sensors (cw_water_data) - * - Pulse flow meters (cw_pulse_meters) - * - Any other sensor using created_at - */ - -describe('Non-Traffic Device Integration Tests', () => { - let supabase: ReturnType; - const timezone = 'Asia/Tokyo'; - - beforeAll(() => { - const supabaseUrl = - process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; - const supabaseKey = - process.env.PUBLIC_SUPABASE_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; - - supabase = createClient(supabaseUrl, supabaseKey); - }); - - // Helper function - same timezone conversion logic as DeviceDataService - function convertUserTimezoneToUTC(date: Date, timezone: string): Date { - if (timezone === 'UTC') { - return date; - } - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hour = date.getHours(); - const minute = date.getMinutes(); - const second = date.getSeconds(); - - const dt = DateTime.fromObject( - { - year, - month, - day, - hour, - minute, - second - }, - { zone: timezone } - ); - - return dt.toUTC().toJSDate(); - } - - // Helper function to get devices for testing - async function getDevicesByTable(tableName: string, limit = 1) { - // First get devices, then get their types separately to avoid relationship issues - const { data: rawDevices, error: devicesError } = await supabase - .from('cw_devices') - .select('dev_eui, name, type') - .limit(limit * 5); // Get more devices to filter - - if (devicesError || !rawDevices) { - console.warn(`Error getting devices:`, devicesError?.message); - return []; - } - - const devices = rawDevices as DeviceRow[]; - - // Get device types - const { data: rawDeviceTypes, error: typesError } = await supabase - .from('cw_device_type') - .select('id, name, data_table_v2') - .eq('data_table_v2', tableName); - - if (typesError || !rawDeviceTypes || rawDeviceTypes.length === 0) { - console.warn(`No device types found for table ${tableName}:`, typesError?.message); - return []; - } - - const deviceTypes = rawDeviceTypes as DeviceTypeRow[]; - - // Filter devices by type and format response - const typeIds = deviceTypes.map((t) => t.id); - const matchingDevices = devices - .filter((device: any) => typeIds.includes(device.type)) - .slice(0, limit) - .map((device: any) => { - const deviceType = deviceTypes.find((t) => t.id === device.type); - return { - dev_eui: device.dev_eui as string, - name: device.name as string, - cw_device_type: { - name: deviceType?.name as string, - data_table_v2: deviceType?.data_table_v2 as string - } - }; - }); - - return matchingDevices; - } - - // Helper function to query data with created_at field - async function queryDeviceData( - tableName: string, - devEui: string, - startDate: Date, - endDate: Date - ) { - const utcStartDate = convertUserTimezoneToUTC(startDate, timezone); - const utcEndDate = convertUserTimezoneToUTC(endDate, timezone); - - const { data, error, count } = await supabase - .from(tableName) - .select('*', { count: 'exact' }) - .eq('dev_eui', devEui) - .gte('created_at', utcStartDate.toISOString()) - .lte('created_at', utcEndDate.toISOString()) - .order('created_at', { ascending: false }) - .limit(100); // Reasonable limit for testing - - // Type assertion for data records - return { - data: data as Array> | null, - error, - count - }; - } - - describe('Air Quality Sensor Tests (cw_air_data)', () => { - it('should find air quality devices and query data with created_at field', async () => { - // Arrange - const devices = await getDevicesByTable('cw_air_data'); - - if (devices.length === 0) { - console.warn('⚠️ No air quality devices found, skipping test'); - return; - } - - const testDevice = devices[0]; - const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); // August 1st midnight Tokyo - const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); // End of August - - // Act - const { data, error, count } = await queryDeviceData( - 'cw_air_data', - testDevice.dev_eui, - startDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - expect(Array.isArray(data)).toBe(true); - - if (data && data.length > 0) { - // Verify the data structure includes created_at field - expect(data[0]).toHaveProperty('created_at'); - expect(data[0]).toHaveProperty('dev_eui'); - expect(data[0].dev_eui).toBe(testDevice.dev_eui); - - // Verify created_at timestamps are properly formatted - const sampleRecord = data[0]; - expect(sampleRecord.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - - console.log( - `✅ VERIFIED: Air sensor ${testDevice.dev_eui} - Found ${count} records with created_at field` - ); - } - }, 15000); - - it('should convert air sensor timestamps correctly for Tokyo timezone', async () => { - // Arrange - const devices = await getDevicesByTable('cw_air_data'); - - if (devices.length === 0) { - console.warn('⚠️ No air quality devices found, skipping timestamp test'); - return; - } - - const testDevice = devices[0]; - // Test a specific day to check timezone conversion - const testDate = new Date(2025, 7, 15, 0, 0, 0, 0); // August 15th midnight Tokyo - const endDate = new Date(2025, 7, 15, 23, 59, 59, 999); - - // Act - const { data, error } = await queryDeviceData( - 'cw_air_data', - testDevice.dev_eui, - testDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - - if (data && data.length > 0) { - // Verify timezone conversion by checking UTC timestamps - const utcStartExpected = convertUserTimezoneToUTC(testDate, timezone); - const utcEndExpected = convertUserTimezoneToUTC(endDate, timezone); - - // Check that the first record's timestamp is within our expected range - const firstRecord = data[data.length - 1]; // Last in desc order = earliest - const recordTime = new Date(firstRecord.created_at); - - expect(recordTime.getTime()).toBeGreaterThanOrEqual(utcStartExpected.getTime()); - expect(recordTime.getTime()).toBeLessThanOrEqual(utcEndExpected.getTime()); - - console.log( - `✅ VERIFIED: Air sensor timezone conversion - UTC range ${utcStartExpected.toISOString()} to ${utcEndExpected.toISOString()}` - ); - } - }, 15000); - }); - - describe('Soil Moisture Sensor Tests (cw_soil_data)', () => { - it('should find soil sensors and verify created_at field usage', async () => { - // Arrange - const devices = await getDevicesByTable('cw_soil_data'); - - if (devices.length === 0) { - console.warn('⚠️ No soil moisture devices found, skipping test'); - return; - } - - const testDevice = devices[0]; - const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); - const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); - - // Act - const { data, error, count } = await queryDeviceData( - 'cw_soil_data', - testDevice.dev_eui, - startDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - - if (data && data.length > 0) { - // Verify soil-specific fields and created_at - const sampleRecord = data[0]; - expect(sampleRecord).toHaveProperty('created_at'); - expect(sampleRecord).toHaveProperty('moisture'); - expect(sampleRecord).toHaveProperty('temperature_c'); - expect(sampleRecord).toHaveProperty('ph'); - expect(sampleRecord).toHaveProperty('ec'); - - console.log( - `✅ VERIFIED: Soil sensor ${testDevice.dev_eui} - Found ${count} records with soil data fields` - ); - } - }, 15000); - - it('should handle soil sensor early morning readings correctly', async () => { - // Arrange - const devices = await getDevicesByTable('cw_soil_data'); - - if (devices.length === 0) return; - - const testDevice = devices[0]; - // Test early morning reading (common for soil sensors) - const earlyMorning = new Date(2025, 7, 15, 5, 0, 0, 0); // 5 AM Tokyo - const endTime = new Date(2025, 7, 15, 7, 0, 0, 0); // 7 AM Tokyo - - // Act - const { data, error } = await queryDeviceData( - 'cw_soil_data', - testDevice.dev_eui, - earlyMorning, - endTime - ); - - // Assert - expect(error).toBeNull(); - - if (data && data.length > 0) { - // Verify UTC conversion: 5-7 AM Tokyo = 8-10 PM previous day UTC - const utcStart = convertUserTimezoneToUTC(earlyMorning, timezone); - expect(utcStart.getUTCHours()).toBe(20); // 8 PM UTC previous day - - console.log( - `✅ VERIFIED: Soil sensor early morning conversion - 5 AM Tokyo = ${utcStart.toISOString()}` - ); - } - }, 15000); - }); - - describe('Water Level Sensor Tests (cw_water_data)', () => { - it('should find water sensors and validate created_at field', async () => { - // Arrange - const devices = await getDevicesByTable('cw_water_data'); - - if (devices.length === 0) { - console.warn('⚠️ No water level devices found, skipping test'); - return; - } - - const testDevice = devices[0]; - const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); - const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); - - // Act - const { data, error, count } = await queryDeviceData( - 'cw_water_data', - testDevice.dev_eui, - startDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - - if (data && data.length > 0) { - // Verify water-specific fields and created_at - const sampleRecord = data[0]; - expect(sampleRecord).toHaveProperty('created_at'); - expect(sampleRecord).toHaveProperty('deapth_cm'); - expect(sampleRecord).toHaveProperty('pressure'); - expect(sampleRecord).toHaveProperty('temperature_c'); - - console.log( - `✅ VERIFIED: Water sensor ${testDevice.dev_eui} - Found ${count} records with water data fields` - ); - } - }, 15000); - }); - - describe('Pulse Meter Tests (cw_pulse_meters)', () => { - it('should find pulse meters and verify created_at field', async () => { - // Arrange - const devices = await getDevicesByTable('cw_pulse_meters'); - - if (devices.length === 0) { - console.warn('⚠️ No pulse meter devices found, skipping test'); - return; - } - - const testDevice = devices[0]; - const startDate = new Date(2025, 7, 1, 0, 0, 0, 0); - const endDate = new Date(2025, 7, 31, 23, 59, 59, 999); - - // Act - const { data, error, count } = await queryDeviceData( - 'cw_pulse_meters', - testDevice.dev_eui, - startDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - - if (data && data.length > 0) { - // Verify pulse meter fields and created_at - const sampleRecord = data[0]; - expect(sampleRecord).toHaveProperty('created_at'); - expect(sampleRecord).toHaveProperty('count'); - expect(sampleRecord).toHaveProperty('litersPerPulse'); - expect(sampleRecord).toHaveProperty('periodCount'); - - console.log( - `✅ VERIFIED: Pulse meter ${testDevice.dev_eui} - Found ${count} records with pulse data fields` - ); - } - }, 15000); - }); - - describe('Multi-Device Type Comparison', () => { - it('should verify all non-traffic devices use created_at field consistently', async () => { - // Arrange - const nonTrafficTables = ['cw_air_data', 'cw_soil_data', 'cw_water_data', 'cw_pulse_meters']; - - const results = []; - - // Act - Check each device type - for (const tableName of nonTrafficTables) { - const devices = await getDevicesByTable(tableName, 1); - - if (devices.length > 0) { - const testDevice = devices[0]; - const testDate = new Date(2025, 7, 15, 12, 0, 0, 0); // Noon Tokyo - const endDate = new Date(2025, 7, 15, 12, 59, 59, 999); - - const { data, error } = await queryDeviceData( - tableName, - testDevice.dev_eui, - testDate, - endDate - ); - - results.push({ - tableName, - deviceEui: testDevice.dev_eui, - hasData: data && data.length > 0, - error: error?.message, - usesCreatedAt: data && data.length > 0 && 'created_at' in data[0] - }); - } else { - results.push({ - tableName, - deviceEui: null, - hasData: false, - error: 'No devices found', - usesCreatedAt: null - }); - } - } - - // Assert - console.log('📊 DEVICE TYPE COMPARISON:'); - results.forEach((result) => { - console.log( - ` ${result.tableName}: Device ${result.deviceEui}, Uses created_at: ${result.usesCreatedAt}` - ); - - if (result.hasData) { - expect(result.usesCreatedAt).toBe(true); - expect(result.error).toBeUndefined(); - } - }); - - // At least one device type should have data for this test to be meaningful - const devicesWithData = results.filter((r) => r.hasData); - expect(devicesWithData.length).toBeGreaterThan(0); - }, 25000); - - it('should verify timezone conversion consistency across device types', async () => { - // Arrange - const testTime = new Date(2025, 7, 15, 15, 30, 0, 0); // 3:30 PM Tokyo - const utcExpected = convertUserTimezoneToUTC(testTime, timezone); - - // Act & Assert - const nonTrafficTables = ['cw_air_data', 'cw_soil_data', 'cw_water_data']; - - for (const tableName of nonTrafficTables) { - const devices = await getDevicesByTable(tableName, 1); - - if (devices.length > 0) { - const testDevice = devices[0]; - const { data, error } = await queryDeviceData( - tableName, - testDevice.dev_eui, - testTime, - new Date(testTime.getTime() + 3600000) // +1 hour - ); - - expect(error).toBeNull(); - - // All device types should use the same timezone conversion - // 3:30 PM Tokyo = 6:30 AM UTC - expect(utcExpected.getUTCHours()).toBe(6); - expect(utcExpected.getUTCMinutes()).toBe(30); - - console.log( - `✅ VERIFIED: ${tableName} timezone conversion consistent - 3:30 PM Tokyo = ${utcExpected.toISOString()}` - ); - } - } - }, 20000); - }); - - describe('Data Quality for Non-Traffic Devices', () => { - it('should verify realistic data patterns for sensor devices', async () => { - // Arrange - const devices = await getDevicesByTable('cw_air_data'); - - if (devices.length === 0) return; - - const testDevice = devices[0]; - const testDate = new Date(2025, 7, 15, 0, 0, 0, 0); - const endDate = new Date(2025, 7, 15, 23, 59, 59, 999); - - // Act - const { data, error } = await queryDeviceData( - 'cw_air_data', - testDevice.dev_eui, - testDate, - endDate - ); - - // Assert - expect(error).toBeNull(); - - if (data && data.length > 0) { - // Verify timestamps are in chronological order (descending) - let previousTime = new Date(data[0].created_at); - - data.slice(1, 5).forEach((record) => { - // Check first few records - const currentTime = new Date(record.created_at); - expect(currentTime.getTime()).toBeLessThanOrEqual(previousTime.getTime()); - previousTime = currentTime; - }); - - // Verify data contains reasonable sensor values - const recordsWithData = data.filter( - (record) => - record.temperature_c !== null || record.humidity !== null || record.pressure !== null - ); - - expect(recordsWithData.length).toBeGreaterThan(0); - - console.log( - `✅ VERIFIED: Air sensor data quality - ${recordsWithData.length}/${data.length} records have sensor values` - ); - } - }, 15000); - - it('should verify created_at timestamps are within expected UTC range', async () => { - // Arrange - const devices = await getDevicesByTable('cw_soil_data'); - - if (devices.length === 0) return; - - const testDevice = devices[0]; - const tokyoMidnight = new Date(2025, 7, 15, 0, 0, 0, 0); // August 15th midnight Tokyo - const tokyoNoon = new Date(2025, 7, 15, 12, 0, 0, 0); // August 15th noon Tokyo - - // Act - const { data, error } = await queryDeviceData( - 'cw_soil_data', - testDevice.dev_eui, - tokyoMidnight, - tokyoNoon - ); - - // Assert - expect(error).toBeNull(); - - if (data && data.length > 0) { - // Expected UTC range: August 14th 3 PM to August 15th 3 AM - const utcStart = convertUserTimezoneToUTC(tokyoMidnight, timezone); - const utcEnd = convertUserTimezoneToUTC(tokyoNoon, timezone); - - expect(utcStart.getUTCDate()).toBe(14); // Previous day - expect(utcStart.getUTCHours()).toBe(15); // 3 PM UTC - - expect(utcEnd.getUTCDate()).toBe(15); // Same day - expect(utcEnd.getUTCHours()).toBe(3); // 3 AM UTC - - // Verify actual data timestamps fall within this range - data.forEach((record) => { - const recordTime = new Date(record.created_at); - expect(recordTime.getTime()).toBeGreaterThanOrEqual(utcStart.getTime()); - expect(recordTime.getTime()).toBeLessThanOrEqual(utcEnd.getTime()); - }); - - console.log( - `✅ VERIFIED: Soil sensor UTC range - ${utcStart.toISOString()} to ${utcEnd.toISOString()}` - ); - } - }, 15000); - }); -}); diff --git a/src/lib/tests/PDFReportIntegration.test.ts b/src/lib/tests/PDFReportIntegration.test.ts deleted file mode 100644 index cc01f3f2..00000000 --- a/src/lib/tests/PDFReportIntegration.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, beforeAll } from 'vitest'; -import { createClient } from '@supabase/supabase-js'; -import { DateTime } from 'luxon'; - -/** - * PDF REPORT API INTEGRATION TESTS - TEMPERATURE DEVICE - * - * These tests validate the complete PDF report generation flow for temperature devices, - * ensuring proper timezone conversion throughout the entire process. - * - * Test Device: 373632336F32840A (Temperature sensor) - * Focus: created_at field timezone conversion in PDF report generation - */ - -describe('PDF Report API Integration Tests - Temperature Device', () => { - let supabase: ReturnType; - const tempDeviceEui = '373632336F32840A'; - const timezone = 'Asia/Tokyo'; - const baseUrl = 'http://localhost:5173'; - - beforeAll(() => { - // Use environment variables if available, otherwise use test credentials - const supabaseUrl = - process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; - const supabaseKey = - process.env.PUBLIC_SUPABASE_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; - - supabase = createClient(supabaseUrl, supabaseKey); - }); - - // Helper function to make authenticated PDF API requests - async function fetchPDFReport( - devEui: string, - startDate: string, - endDate: string, - timezone: string - ) { - const url = `${baseUrl}/api/devices/${devEui}/pdf?start=${startDate}&end=${endDate}&timezone=${timezone}`; - - try { - const response = await fetch(url, { - headers: { - Accept: 'application/pdf' - // Note: In real scenario, you'd need JWT authentication - // 'Authorization': `Bearer ${jwtToken}` - } - }); - - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - contentType: response.headers.get('content-type'), - contentLength: response.headers.get('content-length'), - body: response.ok ? await response.arrayBuffer() : await response.text() - }; - } catch (error) { - // If server is not running, return null to skip the test - if (error instanceof Error && error.message.includes('ECONNREFUSED')) { - console.warn('⚠️ Development server not running, skipping PDF API test'); - return null; - } - throw error; - } - } - - describe('Temperature Device Data Validation', () => { - it('should verify temperature device exists and has data', async () => { - // Arrange - Query temperature device directly - const { data: device, error: deviceError } = await supabase - .from('cw_devices') - .select('dev_eui, location_id') - .eq('dev_eui', tempDeviceEui) - .single(); - - // Assert device exists - expect(deviceError).toBeNull(); - expect(device).toBeDefined(); - expect(device?.dev_eui).toBe(tempDeviceEui); - - console.log(`✅ VERIFIED: Temperature device ${tempDeviceEui} exists`); - console.log(` Location ID: ${device?.location_id}`); - - // Check if device has recent data - const { data: recentData, error: dataError } = await supabase - .from('cw_air_data') - .select('created_at, temperature_c, humidity, pressure') - .eq('dev_eui', tempDeviceEui) - .order('created_at', { ascending: false }) - .limit(5); - - expect(dataError).toBeNull(); - - if (recentData && recentData.length > 0) { - console.log(`✅ VERIFIED: Found ${recentData.length} recent temperature records`); - recentData.forEach((record: any, index: number) => { - const tokyoTime = DateTime.fromISO(record.created_at as string).setZone(timezone); - console.log( - ` ${index + 1}. Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${record.temperature_c}°C` - ); - }); - } else { - console.warn('⚠️ No recent data found for temperature device'); - } - }, 10000); - - it('should validate temperature data timezone conversion for PDF reports', async () => { - // Arrange - Get a sample of temperature data - const sampleDate = DateTime.now().setZone(timezone).minus({ days: 7 }).toISODate(); // 7 days ago - - const startDt = DateTime.fromISO(sampleDate + 'T00:00:00', { zone: timezone }).startOf('day'); - const endDt = DateTime.fromISO(sampleDate + 'T23:59:59', { zone: timezone }).endOf('day'); - - // Helper function - same as DeviceDataService - function convertUserTimezoneToUTC(date: Date, timezone: string): Date { - if (timezone === 'UTC') return date; - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hour = date.getHours(); - const minute = date.getMinutes(); - const second = date.getSeconds(); - - const dt = DateTime.fromObject( - { - year, - month, - day, - hour, - minute, - second - }, - { zone: timezone } - ); - - return dt.toUTC().toJSDate(); - } - - const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); - const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); - - // Act - Query temperature data with timezone conversion - const { data, error } = await supabase - .from('cw_air_data') - .select('created_at, temperature_c, humidity, pressure') - .eq('dev_eui', tempDeviceEui) - .gte('created_at', utcStartDate.toISOString()) - .lte('created_at', utcEndDate.toISOString()) - .order('created_at', { ascending: true }); - - // Assert - expect(error).toBeNull(); - - if (data && data.length > 0) { - console.log(`✅ VERIFIED: Found ${data.length} temperature records for ${sampleDate}`); - - // Validate timezone conversion - data.slice(0, 3).forEach((record: any, index: number) => { - const utcTime = DateTime.fromISO(record.created_at as string); - const tokyoTime = utcTime.setZone(timezone); - - // Should be within the expected date in Tokyo timezone - expect(tokyoTime.toISODate()).toBe(sampleDate); - - console.log( - ` ${index + 1}. UTC: ${record.created_at} → Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${record.temperature_c}°C` - ); - }); - - // Validate temperature data quality - const avgTemp = - data.reduce((sum: number, record: any) => sum + (Number(record.temperature_c) || 0), 0) / - data.length; - expect(avgTemp).toBeGreaterThan(-50); // Reasonable temperature range - expect(avgTemp).toBeLessThan(60); - - console.log(`✅ VERIFIED: Temperature data quality - Avg: ${avgTemp.toFixed(1)}°C`); - } else { - console.warn(`⚠️ No temperature data found for ${sampleDate}`); - } - }, 15000); - }); - - describe('PDF Report API Endpoint Tests', () => { - it('should generate PDF report for temperature device with proper timezone handling', async () => { - // Arrange - Use a recent date range - const endDate = DateTime.now().setZone(timezone).minus({ days: 1 }).toISODate(); - const startDate = DateTime.now().setZone(timezone).minus({ days: 3 }).toISODate(); - - // Act - const response = await fetchPDFReport(tempDeviceEui, startDate!, endDate!, timezone); - - // Skip test if server not running - if (response === null) return; - - // Assert - if (response.status === 401) { - console.warn('⚠️ PDF API requires authentication, skipping content validation'); - expect(response.status).toBe(401); // Expected for unauthenticated request - return; - } - - if (response.ok) { - expect(response.contentType).toContain('application/pdf'); - expect(response.body).toBeInstanceOf(ArrayBuffer); - const pdfSize = (response.body as ArrayBuffer).byteLength; - expect(pdfSize).toBeGreaterThan(1000); // Should be a substantial PDF - - console.log(`✅ VERIFIED: PDF generated successfully`); - console.log(` Content Type: ${response.contentType}`); - console.log(` Size: ${pdfSize} bytes`); - } else { - console.warn(`⚠️ PDF generation failed: ${response.status} ${response.statusText}`); - console.warn(` Response: ${response.body}`); - } - }, 20000); - - it('should handle invalid date ranges gracefully for temperature device', async () => { - // Arrange - Invalid date range - const startDate = '2025-12-31'; // Future date - const endDate = '2025-12-31'; - - // Act - const response = await fetchPDFReport(tempDeviceEui, startDate, endDate, timezone); - - // Skip test if server not running - if (response === null) return; - - // Assert - if (response.status === 401) { - console.warn('⚠️ PDF API requires authentication, test passed for auth check'); - return; - } - - // Should handle no data gracefully - if (!response.ok) { - expect(response.status).toBeOneOf([404, 400]); // No data found or bad request - console.log( - `✅ VERIFIED: Proper error handling for invalid date range: ${response.status}` - ); - } - }, 10000); - - it('should validate PDF report date parameter format handling', async () => { - // Arrange - Test various date formats - const testCases = [ - { start: '2025-08-01', end: '2025-08-01', valid: true }, - { start: '2025-8-1', end: '2025-8-1', valid: false }, // Invalid format - { start: 'invalid-date', end: '2025-08-01', valid: false }, - { start: '2025-08-01', end: '2025-07-31', valid: false } // End before start - ]; - - for (const testCase of testCases) { - // Act - const response = await fetchPDFReport( - tempDeviceEui, - testCase.start, - testCase.end, - timezone - ); - - // Skip test if server not running - if (response === null) continue; - - // Assert - if (response.status === 401) { - console.warn('⚠️ Skipping date format test due to auth requirement'); - continue; - } - - if (testCase.valid) { - // Valid dates might return 404 (no data) or 200 (success) - expect([200, 404]).toContain(response.status); - } else { - // Invalid dates should return 400 (bad request) - expect(response.status).toBe(400); - } - - console.log( - `✅ Date format test: ${testCase.start} to ${testCase.end} → ${response.status} ${response.statusText}` - ); - } - }, 30000); - }); - - describe('PDF Report Timezone Edge Cases', () => { - it('should handle month boundary timezone conversion for temperature reports', async () => { - // Arrange - Date range spanning month boundary - const startDate = '2025-07-31'; // Last day of July - const endDate = '2025-08-01'; // First day of August - - // Test timezone conversion logic (same as PDF server) - let start = new Date(startDate); - let end = new Date(endDate); - - const userStartDate = DateTime.fromJSDate(start).setZone(timezone).startOf('day'); - const userEndDate = DateTime.fromJSDate(end).setZone(timezone).endOf('day'); - - const utcStartDate = userStartDate.toUTC().toJSDate(); - const utcEndDate = userEndDate.toUTC().toJSDate(); - - // Assert timezone conversion - expect(utcStartDate.getUTCMonth()).toBe(6); // July (0-indexed) - expect(utcEndDate.getUTCMonth()).toBe(7); // August (0-indexed) - - // Query data with this range - const { data, error } = await supabase - .from('cw_air_data') - .select('created_at, temperature_c') - .eq('dev_eui', tempDeviceEui) - .gte('created_at', utcStartDate.toISOString()) - .lte('created_at', utcEndDate.toISOString()) - .limit(10); - - expect(error).toBeNull(); - - if (data && data.length > 0) { - // Verify data spans the month boundary correctly - const tokyoDates = data.map((record: any) => { - const tokyoTime = DateTime.fromISO(record.created_at as string).setZone(timezone); - return tokyoTime.toISODate(); - }); - - const uniqueDates = [...new Set(tokyoDates)]; - console.log( - `✅ VERIFIED: Month boundary data spans ${uniqueDates.length} dates in Tokyo timezone` - ); - console.log(` Dates: ${uniqueDates.join(', ')}`); - } - }, 15000); - - it('should validate timezone parameter consistency across PDF components', () => { - // Arrange - Test different timezone parameters - const testTimezones = ['Asia/Tokyo', 'America/New_York', 'Europe/London', 'UTC']; - const testDate = '2025-08-01'; - - testTimezones.forEach((tz) => { - // Act - Simulate PDF server timezone conversion - const date = new Date(testDate); - const userDate = DateTime.fromJSDate(date).setZone(tz).startOf('day'); - const utcDate = userDate.toUTC().toJSDate(); - - // Assert - expect(utcDate).toBeInstanceOf(Date); - expect(utcDate.toISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - - console.log( - `✅ Timezone conversion: ${tz} ${testDate} 00:00 → UTC ${utcDate.toISOString()}` - ); - }); - }); - }); -}); diff --git a/src/lib/tests/PDFReportTimezone.test.ts b/src/lib/tests/PDFReportTimezone.test.ts deleted file mode 100644 index 365ef166..00000000 --- a/src/lib/tests/PDFReportTimezone.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { DateTime } from 'luxon'; -import { DeviceDataService } from '../services/DeviceDataService'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { DeviceDataRecord } from '../models/DeviceDataRecord'; -import type { ReportAlertPoint } from '../models/Report'; - -/** - * CRITICAL PDF REPORT TIMEZONE TESTS - TEMPERATURE DEVICES - * - * These tests ensure that PDF report generation for temperature devices - * properly handles timezone conversion for the created_at field. - * - * Test Device: 373632336F32840A (Temperature sensor) - * Key requirement: created_at timestamps must be converted properly between - * user timezone and UTC for accurate report generation. - */ - -describe('PDF Report Temperature Device Timezone Tests', () => { - let deviceDataService: DeviceDataService; - let mockSupabase: SupabaseClient; - - const tempDeviceEui = '373632336F32840A'; - const timezone = 'Asia/Tokyo'; - - // Mock temperature device data structure - const mockTempDevice = { - dev_eui: tempDeviceEui, - location_id: 'test-location-1', - cw_device_type: { - data_table_v2: 'cw_air_data', // Temperature sensors use air data table - device_type: 'air_sensor' - } - }; - - // Mock temperature data records - const mockTempData: DeviceDataRecord[] = [ - { - dev_eui: tempDeviceEui, - created_at: '2025-08-01T06:00:00+00:00', // UTC: Aug 1st 6AM = Tokyo: Aug 1st 3PM - temperature_c: 25.5, - humidity: 60.2, - pressure: 1013.25 - }, - { - dev_eui: tempDeviceEui, - created_at: '2025-08-01T03:00:00+00:00', // UTC: Aug 1st 3AM = Tokyo: Aug 1st 12PM - temperature_c: 28.3, - humidity: 58.7, - pressure: 1012.8 - }, - { - dev_eui: tempDeviceEui, - created_at: '2025-07-31T21:00:00+00:00', // UTC: Jul 31st 9PM = Tokyo: Aug 1st 6AM - temperature_c: 22.1, - humidity: 65.4, - pressure: 1014.1 - } - ]; - - beforeEach(() => { - // Mock Supabase client with temperature device responses - mockSupabase = { - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: mockTempDevice, - error: null - }), - gte: vi.fn().mockReturnValue({ - lte: vi.fn().mockReturnValue({ - order: vi.fn().mockResolvedValue({ - data: mockTempData, - error: null - }) - }) - }) - }) - }) - }), - rpc: vi.fn().mockResolvedValue({ - data: mockTempData, - error: null - }) - } as any; - - deviceDataService = new DeviceDataService(mockSupabase); - }); - - describe('PDF Report Date Range Processing', () => { - it('should properly convert PDF report date parameters to UTC for temperature device', () => { - // Arrange - Same logic as PDF server - const startDateParam = '2025-08-01'; - const endDateParam = '2025-08-01'; - - // Act - Convert dates like PDF server does - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - // Convert to user timezone and include full day (PDF server logic) - const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); - const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); - - // Convert back to UTC for database queries - const utcStartDate = userStartDate.toUTC().toJSDate(); - const utcEndDate = userEndDate.toUTC().toJSDate(); - - // Assert - Check the conversion matches expected values - expect(utcStartDate.toISOString()).toBe('2025-07-31T15:00:00.000Z'); // Aug 1st 00:00 Tokyo = Jul 31st 15:00 UTC - expect(utcEndDate.toISOString()).toBe('2025-08-01T14:59:59.999Z'); // Aug 1st 23:59 Tokyo = Aug 1st 14:59 UTC - - console.log('✅ PDF Report Date Range Conversion:'); - console.log(` Tokyo Range: ${userStartDate.toISO()} to ${userEndDate.toISO()}`); - console.log(` UTC Range: ${utcStartDate.toISOString()} to ${utcEndDate.toISOString()}`); - }); - - it('should handle multi-day PDF report date ranges correctly', () => { - // Arrange - Multi-day report - const startDateParam = '2025-08-01'; - const endDateParam = '2025-08-05'; - - // Act - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); - const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); - - const utcStartDate = userStartDate.toUTC().toJSDate(); - const utcEndDate = userEndDate.toUTC().toJSDate(); - - // Assert - expect(utcStartDate.toISOString()).toBe('2025-07-31T15:00:00.000Z'); - expect(utcEndDate.toISOString()).toBe('2025-08-05T14:59:59.999Z'); - - // Should span 5 days in Tokyo time - const daysDiff = userEndDate.diff(userStartDate, 'days').days; - expect(Math.ceil(daysDiff)).toBe(5); - }); - }); - - describe('Temperature Device Data Timezone Conversion', () => { - it('should convert temperature data created_at timestamps to user timezone for PDF display', async () => { - // Arrange - const startDate = new Date('2025-07-31T15:00:00.000Z'); // UTC - const endDate = new Date('2025-08-01T14:59:59.999Z'); // UTC - - // Act - Get data like PDF server does - const result = await deviceDataService.getDeviceDataByDateRange( - tempDeviceEui, - startDate, - endDate, - timezone - ); - - // Assert - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - - // Verify timezone conversion for each record - if (result && result.length > 0) { - result.forEach((record) => { - // created_at should be converted to Tokyo timezone - const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); - - // Should be August 1st in Tokyo timezone - expect(tokyoTime.year).toBe(2025); - expect(tokyoTime.month).toBe(8); // August - expect(tokyoTime.day).toBe(1); - - console.log( - `✅ Temperature Record: UTC ${record.created_at} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${(record as any).temperature_c}°C` - ); - }); - } - }); - - it('should handle temperature device report data with alert points', async () => { - // Arrange - Report with temperature alert points - const startDate = new Date('2025-07-31T15:00:00.000Z'); - const endDate = new Date('2025-08-01T14:59:59.999Z'); - - const alertPoints: ReportAlertPoint[] = [ - { - id: 1, - data_point_key: 'temperature_c', - name: 'High Temperature Alert', - operator: 'gt', - value: 25.0, - min: 25.0, - max: null, - hex_color: '#ff0000', - created_at: '2025-07-31T00:00:00Z', - report_id: 'report-1', - user_id: 'user-1' - } - ]; - - // Act - const result = await deviceDataService.getDeviceDataForReport({ - devEui: tempDeviceEui, - startDate, - endDate, - timezone, - columns: alertPoints.map((p) => p.data_point_key), - ops: alertPoints.map((p) => p.operator || 'gt'), - mins: alertPoints.map((p) => p.min || p.value || 0), - maxs: alertPoints.map((p) => p.max || null), - intervalMinutes: 30 - }); - - // Assert - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - - // Verify the report data has proper timezone conversion - if (result && result.length > 0) { - result.forEach((record) => { - const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); - expect(tokyoTime.month).toBe(8); // Should be August in Tokyo - - // Should have temperature data - expect((record as any).temperature_c).toBeDefined(); - expect(typeof (record as any).temperature_c).toBe('number'); - }); - } - }); - - it('should convert report data timestamps to the requested timezone', async () => { - const rpcRows: DeviceDataRecord[] = [ - { - dev_eui: tempDeviceEui, - created_at: '2025-11-08T15:00:00Z', - temperature_c: 24.2 - }, - { - dev_eui: tempDeviceEui, - created_at: '2025-11-08T14:30:00Z', - temperature_c: 22.9 - } - ]; - - (mockSupabase.rpc as any).mockResolvedValueOnce({ - data: rpcRows, - error: null - }); - - const startDate = new Date('2025-11-08T00:00:00.000Z'); - const endDate = new Date('2025-11-09T00:00:00.000Z'); - const result = await deviceDataService.getDeviceDataForReport({ - devEui: tempDeviceEui, - startDate, - endDate, - timezone, - intervalMinutes: 30 - }); - - expect(result).toHaveLength(2); - expect(result[0].created_at).toContain('+09:00'); - expect(DateTime.fromISO(result[0].created_at).toISO()).toBe('2025-11-09T00:00:00.000+09:00'); - expect(DateTime.fromISO(result[1].created_at).toISO()).toBe('2025-11-08T23:30:00.000+09:00'); - }); - }); - - describe('PDF Report Data Sorting and Formatting', () => { - it('should sort temperature data by created_at for chronological PDF display', async () => { - // Arrange - const startDate = new Date('2025-07-31T15:00:00.000Z'); - const endDate = new Date('2025-08-01T14:59:59.999Z'); - - // Act - const deviceData = await deviceDataService.getDeviceDataByDateRange( - tempDeviceEui, - startDate, - endDate, - timezone - ); - - // Sort like PDF server does - if (deviceData) { - deviceData.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return dateA - dateB; // Ascending - }); - - // Assert - expect(deviceData.length).toBeGreaterThan(0); - - // Verify chronological order - for (let i = 1; i < deviceData.length; i++) { - const prevTime = new Date(deviceData[i - 1].created_at).getTime(); - const currentTime = new Date(deviceData[i].created_at).getTime(); - expect(currentTime).toBeGreaterThanOrEqual(prevTime); - } - - console.log('✅ Temperature Data Chronological Order:'); - deviceData.forEach((record, index) => { - const tokyoTime = DateTime.fromISO(record.created_at).setZone(timezone); - console.log( - ` ${index + 1}. Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}, Temp: ${(record as any).temperature_c}°C` - ); - }); - } - }); - - it('should validate temperature data fields for PDF table generation', async () => { - // Arrange - const startDate = new Date('2025-07-31T15:00:00.000Z'); - const endDate = new Date('2025-08-01T14:59:59.999Z'); - - // Act - const deviceData = await deviceDataService.getDeviceDataByDateRange( - tempDeviceEui, - startDate, - endDate, - timezone - ); - - // Assert - expect(deviceData).toBeDefined(); - - if (deviceData && deviceData.length > 0) { - const firstRecord = deviceData[0]; - - // Should have required fields for PDF generation - expect(firstRecord.created_at).toBeDefined(); - expect(firstRecord.dev_eui).toBe(tempDeviceEui); - - // Temperature-specific fields - expect(firstRecord.temperature_c).toBeDefined(); - expect(typeof firstRecord.temperature_c).toBe('number'); - - // Optional environmental fields - if (firstRecord.humidity !== undefined) { - expect(typeof firstRecord.humidity).toBe('number'); - } - if (firstRecord.pressure !== undefined) { - expect(typeof firstRecord.pressure).toBe('number'); - } - - console.log('✅ Temperature Data Fields Validation:'); - console.log(` Device: ${firstRecord.dev_eui}`); - console.log(` Timestamp: ${firstRecord.created_at}`); - console.log(` Temperature: ${firstRecord.temperature}°C`); - console.log(` Humidity: ${firstRecord.humidity}%`); - console.log(` Pressure: ${firstRecord.pressure} hPa`); - } - }); - }); - - describe('PDF Report Edge Cases', () => { - it('should handle timezone conversion across month boundaries for temperature reports', () => { - // Arrange - Date range that crosses month boundary in Tokyo timezone - const startDateParam = '2025-07-31'; // Last day of July - const endDateParam = '2025-08-01'; // First day of August - - // Act - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - const userStartDate = DateTime.fromJSDate(startDate).setZone(timezone).startOf('day'); - const userEndDate = DateTime.fromJSDate(endDate).setZone(timezone).endOf('day'); - - const utcStartDate = userStartDate.toUTC().toJSDate(); - const utcEndDate = userEndDate.toUTC().toJSDate(); - - // Assert - UTC range should properly span the month boundary - expect(utcStartDate.getUTCMonth()).toBe(6); // July (0-indexed) - expect(utcStartDate.getUTCDate()).toBe(30); // July 30th UTC - - expect(utcEndDate.getUTCMonth()).toBe(7); // August (0-indexed) - expect(utcEndDate.getUTCDate()).toBe(1); // August 1st UTC - - console.log('✅ Month Boundary Timezone Conversion:'); - console.log(` Tokyo: Jul 31st 00:00 → UTC: Jul 30th 15:00`); - console.log(` Tokyo: Aug 1st 23:59 → UTC: Aug 1st 14:59`); - }); - - it('should handle empty temperature data gracefully in PDF reports', async () => { - // Arrange - Mock empty response - const emptySupabase = { - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: mockTempDevice, - error: null - }), - gte: vi.fn().mockReturnValue({ - lte: vi.fn().mockReturnValue({ - order: vi.fn().mockResolvedValue({ - data: [], // Empty data - error: null - }) - }) - }) - }) - }) - }), - rpc: vi.fn().mockResolvedValue({ - data: [], - error: null - }) - } as any; - - const emptyDataService = new DeviceDataService(emptySupabase); - - // Act - const result = await emptyDataService.getDeviceDataByDateRange( - tempDeviceEui, - new Date('2025-08-01T00:00:00Z'), - new Date('2025-08-01T23:59:59Z'), - timezone - ); - - // Assert - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - - it('should validate timezone parameter handling for different timezones', () => { - // Arrange - Test different timezone scenarios - const testTimezones = ['Asia/Tokyo', 'America/New_York', 'Europe/London', 'UTC']; - - const testDate = '2025-08-01'; - - testTimezones.forEach((tz) => { - // Act - const startDate = new Date(testDate); - const userStartDate = DateTime.fromJSDate(startDate).setZone(tz).startOf('day'); - const utcStartDate = userStartDate.toUTC().toJSDate(); - - // Assert - expect(utcStartDate).toBeInstanceOf(Date); - expect(utcStartDate.toISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - - console.log(`✅ ${tz}: ${testDate} 00:00 → UTC: ${utcStartDate.toISOString()}`); - }); - }); - }); -}); diff --git a/src/lib/tests/ParseDeviceInstant.test.ts b/src/lib/tests/ParseDeviceInstant.test.ts deleted file mode 100644 index 4a2f790d..00000000 --- a/src/lib/tests/ParseDeviceInstant.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseDeviceInstant } from '$lib/pdf/parseDeviceInstant'; - -describe('parseDeviceInstant', () => { - it('treats offset-less ISO timestamps as UTC then converts to the requested zone', () => { - const dt = parseDeviceInstant('2025-11-08T15:00:00', 'Asia/Tokyo'); - expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); - }); - - it('respects explicit offsets on the source timestamp', () => { - const dt = parseDeviceInstant('2025-11-08T15:00:00-05:00', 'Asia/Tokyo'); - expect(dt.toISO()).toBe('2025-11-09T05:00:00.000+09:00'); - }); - - it('handles Date objects by treating them as UTC instants', () => { - const dt = parseDeviceInstant(new Date('2025-11-08T15:00:00Z'), 'Asia/Tokyo'); - expect(dt.toISO()).toBe('2025-11-09T00:00:00.000+09:00'); - }); -}); diff --git a/src/lib/tests/README.md b/src/lib/tests/README.md deleted file mode 100644 index 8662ecb0..00000000 --- a/src/lib/tests/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# 🕐 Critical Timezone Conversion Tests - -This directory contains comprehensive tests to ensure the timezone conversion functionality for traffic data export remains working correctly. These tests prevent regression of the "very very backwards" traffic patterns bug. - -## 🎯 Critical Validation - -The key requirement verified by these tests: -- **August 1st midnight Tokyo time** should convert to **July 31st 3:00 PM UTC** -- This specific conversion returns **Record ID 35976** in the production database -- Traffic patterns should be realistic (high during day, low at night) - NOT backwards - -## 📋 Test Files - -### 1. `DeviceDataService.timezone.test.ts` - Core Unit Tests -- Tests `convertUserTimezoneToUTC()` and `convertUTCToUserTimezone()` methods -- Validates the critical August 1st midnight Tokyo → July 31st 3PM UTC conversion -- Tests edge cases: DST transitions, leap years, year boundaries -- Validates round-trip conversion accuracy -- **Key Test**: `should convert August 1st midnight Tokyo to July 31st 3PM UTC (CRITICAL TEST)` - -### 2. `TrafficDataIntegration.test.ts` - Database Integration Tests -- Tests complete flow from date range input to database query results -- Validates that Record ID 35976 is found for August 1st midnight Tokyo -- Confirms 744 records exist for full August 2025 (31 days × 24 hours) -- Verifies realistic traffic patterns (day > night traffic) -- Tests data completeness and quality -- **Key Test**: `should find record ID 35976 for August 1st midnight Tokyo (CRITICAL TEST)` - -### 3. `CSVExportValidation.test.ts` - CSV Format Tests -- Tests CSV export formatting and content -- Validates timezone formatting in CSV output -- Confirms realistic traffic patterns in CSV data -- Tests CSV structure and completeness -- Includes mock tests that work without a running server -- **Key Test**: CSV generation logic for timezone conversion - -## 🚀 Running the Tests - -### Quick Critical Test Run -```bash -# Run just the essential timezone tests -./test-timezone-critical.sh -``` - -### Individual Test Files -```bash -# Core timezone conversion logic -pnpm vitest run src/lib/tests/DeviceDataService.timezone.test.ts - -# Database integration validation -pnpm vitest run src/lib/tests/TrafficDataIntegration.test.ts - -# CSV export formatting -pnpm vitest run src/lib/tests/CSVExportValidation.test.ts -``` - -### All Unit Tests -```bash -pnpm test:unit -``` - -## ✅ Success Criteria - -The tests confirm the fix is working when: - -1. **Core Conversion**: August 1st midnight Tokyo converts to July 31st 3PM UTC -2. **Database Verification**: Record ID 35976 is found for the correct timestamp -3. **Data Quality**: Day traffic > night traffic (realistic patterns) -4. **CSV Format**: Proper timezone formatting in exports -5. **Completeness**: Full month of data available (744 records for August) - -## 🔧 Test Results - -Recent test run confirms: -- ✅ **16 timezone conversion tests** - All critical tests passing -- ✅ **5 integration tests** - Database queries working correctly -- ✅ **Record ID 35976** found for August 1st midnight Tokyo -- ✅ **744 records** for complete August 2025 dataset -- ✅ **Realistic traffic patterns** - Day: 2875, Night: 37 (correct ratio) -- ✅ **100% data completeness** for the test month - -## 🛡️ Preventing Regression - -These tests serve as a safety net to ensure: -- Future code changes don't break timezone conversion -- The "backwards traffic pattern" bug doesn't return -- CSV exports continue showing correct timestamps -- Database queries use proper UTC conversion -- API endpoints maintain timezone awareness - -## 📊 Key Test Data Points - -- **Device EUI**: `110110145241600107` -- **Critical Record ID**: `35976` -- **Critical Timestamp**: August 1st 2025 00:00:00 Tokyo → July 31st 2025 15:00:00 UTC -- **Expected Traffic**: 0 people, 6 cars, 1 truck (realistic midnight data) -- **Test Timezone**: `Asia/Tokyo` (UTC+9) - -Run these tests regularly to ensure the timezone conversion logic remains robust and accurate! \ No newline at end of file diff --git a/src/lib/tests/ReportPDFIntegration.test.ts b/src/lib/tests/ReportPDFIntegration.test.ts deleted file mode 100644 index 8d59010e..00000000 --- a/src/lib/tests/ReportPDFIntegration.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, beforeAll } from 'vitest'; -import { createClient } from '@supabase/supabase-js'; -import { DateTime } from 'luxon'; - -/** - * REPORT PDF INTEGRATION TESTS - * - * These tests verify that PDF report generation handles timezone conversion - * correctly for both traffic and non-traffic devices. This ensures reports - * show data in the user's timezone, not backwards patterns. - */ - -describe('Report PDF Integration Tests', () => { - let supabase: ReturnType; - const baseUrl = 'http://localhost:5173'; - const timezone = 'Asia/Tokyo'; - - // Test devices - const trafficDevEui = '110110145241600107'; // Traffic camera - const airDevEui = '2CF7F1C0630000AC'; // Air quality sensor (if available) - - beforeAll(() => { - const supabaseUrl = - process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; - const supabaseKey = - process.env.PUBLIC_SUPABASE_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; - supabase = createClient(supabaseUrl, supabaseKey); - }); - - // Helper function to test PDF API endpoints - async function testPDFEndpoint( - devEui: string, - startDate: string, - endDate: string, - timezone: string - ) { - const url = `${baseUrl}/api/devices/${devEui}/pdf?start=${startDate}&end=${endDate}&timezone=${timezone}`; - - try { - const response = await fetch(url, { - headers: { - Accept: 'application/pdf' - // Add authentication if needed - } - }); - - if (response.status === 401) { - console.warn(`⚠️ Authentication required for PDF API, skipping test for ${devEui}`); - return null; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return { - status: response.status, - contentType: response.headers.get('content-type'), - contentLength: response.headers.get('content-length'), - body: await response.arrayBuffer() - }; - } catch (error) { - if (error instanceof Error && error.message.includes('ECONNREFUSED')) { - console.warn('⚠️ Development server not running, skipping PDF API test'); - return null; - } - throw error; - } - } - - describe('PDF API Timezone Validation', () => { - it('should generate PDF for traffic device with proper timezone handling', async () => { - // Arrange - const startDate = '2025-08-01'; - const endDate = '2025-08-01'; - - // Act - const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); - - if (pdfResponse === null) return; // Skip if server not running or auth required - - // Assert - expect(pdfResponse.status).toBe(200); - expect(pdfResponse.contentType).toBe('application/pdf'); - expect(pdfResponse.body.byteLength).toBeGreaterThan(1000); // Should be a real PDF - - console.log( - `✅ VERIFIED: Traffic device PDF generated (${pdfResponse.body.byteLength} bytes)` - ); - }, 15000); - - it('should handle date range that spans timezone boundaries', async () => { - // Arrange - Test range that crosses midnight Tokyo time - const startDate = '2025-07-31'; // Day before in UTC - const endDate = '2025-08-01'; // Includes our critical record - - // Act - const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); - - if (pdfResponse === null) return; - - // Assert - expect(pdfResponse.status).toBe(200); - expect(pdfResponse.contentType).toBe('application/pdf'); - - console.log(`✅ VERIFIED: Cross-timezone PDF generated successfully`); - }, 15000); - - it('should validate PDF generation for non-traffic devices', async () => { - // Arrange - Test with air quality device - const startDate = '2025-08-15'; - const endDate = '2025-08-15'; - - // First check if this device exists and has data - const { data: deviceCheck } = await supabase - .from('cw_devices') - .select('dev_eui, type') - .eq('dev_eui', airDevEui) - .single(); - - if (!deviceCheck) { - console.warn(`⚠️ Device ${airDevEui} not found, skipping non-traffic PDF test`); - return; - } - - // Act - const pdfResponse = await testPDFEndpoint(airDevEui, startDate, endDate, timezone); - - if (pdfResponse === null) return; - - // Assert - expect(pdfResponse.status).toBe(200); - expect(pdfResponse.contentType).toBe('application/pdf'); - - console.log(`✅ VERIFIED: Non-traffic device PDF generated`); - }, 15000); - }); - - describe('Report Data Quality Validation', () => { - it('should verify report data contains timezone-converted timestamps', async () => { - // Arrange - const startDate = '2025-08-01'; - const endDate = '2025-08-01'; - - // Test direct database query to verify timezone handling - const { data: trafficData, error } = await supabase - .from('cw_traffic2') - .select('id, traffic_hour, people_count, car_count') - .eq('dev_eui', trafficDevEui) - .gte('traffic_hour', '2025-07-31T15:00:00Z') // UTC range for August 1st Tokyo - .lte('traffic_hour', '2025-08-01T14:59:59Z') - .order('traffic_hour', { ascending: true }) - .limit(5); - - // Assert - expect(error).toBeNull(); - expect(trafficData).toBeDefined(); - - if (trafficData && trafficData.length > 0) { - // Validate timezone conversion - trafficData.forEach((record: any) => { - const utcTime = DateTime.fromISO(record.traffic_hour as string); - const tokyoTime = utcTime.setZone(timezone); - - // Should be within August 1st Tokyo time - expect(tokyoTime.year).toBe(2025); - expect(tokyoTime.month).toBe(8); - expect(tokyoTime.day).toBe(1); - - console.log( - `🔍 Record ${record.id}: UTC ${record.traffic_hour} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` - ); - }); - - // Verify we found our critical record - const criticalRecord = trafficData.find((r: any) => r.id === 35976); - if (criticalRecord) { - const criticalTime = DateTime.fromISO(criticalRecord.traffic_hour as string).setZone( - timezone - ); - expect(criticalTime.hour).toBe(0); // Should be midnight Tokyo - console.log(`✅ VERIFIED: Critical record ${criticalRecord.id} is at Tokyo midnight`); - } - } - }, 10000); - - it('should validate report data includes realistic traffic patterns', async () => { - // Arrange - Get full day of traffic data - const startDate = '2025-08-15'; - - const startDt = DateTime.fromISO(startDate + 'T00:00:00', { zone: timezone }).startOf('day'); - const endDt = DateTime.fromISO(startDate + 'T23:59:59', { zone: timezone }).endOf('day'); - - const utcStart = startDt.toUTC().toISO(); - const utcEnd = endDt.toUTC().toISO(); - - // Act - const { data: dayData, error } = await supabase - .from('cw_traffic2') - .select('traffic_hour, people_count, car_count') - .eq('dev_eui', trafficDevEui) - .gte('traffic_hour', utcStart) - .lte('traffic_hour', utcEnd) - .order('traffic_hour', { ascending: true }); - - // Assert - expect(error).toBeNull(); - expect(dayData).toBeDefined(); - - if (dayData && dayData.length > 0) { - // Analyze traffic patterns - const hourlyTraffic: { [hour: number]: number } = {}; - - dayData.forEach((record: any) => { - const utcTime = DateTime.fromISO(record.traffic_hour as string); - const tokyoTime = utcTime.setZone(timezone); - const hour = tokyoTime.hour; - - if (!hourlyTraffic[hour]) hourlyTraffic[hour] = 0; - hourlyTraffic[hour] += - ((record.people_count as number) || 0) + ((record.car_count as number) || 0); - }); - - // Calculate night vs day traffic - const nightHours = [0, 1, 2, 3, 4, 5]; - const dayHours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; - - const nightTraffic = nightHours.reduce((sum, hour) => sum + (hourlyTraffic[hour] || 0), 0); - const dayTraffic = dayHours.reduce((sum, hour) => sum + (hourlyTraffic[hour] || 0), 0); - - // Should have realistic pattern (more traffic during day) - if (dayTraffic > 0 && nightTraffic >= 0) { - expect(dayTraffic).toBeGreaterThanOrEqual(nightTraffic); - console.log( - `✅ VERIFIED: Realistic traffic pattern - Day: ${dayTraffic}, Night: ${nightTraffic}` - ); - } - } - }, 15000); - - it('should verify non-traffic device data timezone conversion', async () => { - // Arrange - Test with air quality device data - const startDate = '2025-08-15'; - const endDate = '2025-08-15'; - - // Create UTC date range for database query - const startDt = DateTime.fromISO(startDate + 'T00:00:00', { zone: timezone }).startOf('day'); - const endDt = DateTime.fromISO(endDate + 'T23:59:59', { zone: timezone }).endOf('day'); - - const utcStart = startDt.toUTC().toISO(); - const utcEnd = endDt.toUTC().toISO(); - - // Act - Query air quality data (using created_at field) - const { data: airData, error } = await supabase - .from('cw_air_data') - .select('dev_eui, created_at, temperature_c, humidity') - .gte('created_at', utcStart) - .lte('created_at', utcEnd) - .order('created_at', { ascending: true }) - .limit(10); - - // Assert - expect(error).toBeNull(); - - if (airData && airData.length > 0) { - // Validate timezone conversion for non-traffic data - airData.forEach((record: any) => { - const utcTime = DateTime.fromISO(record.created_at as string); - const tokyoTime = utcTime.setZone(timezone); - - // Should be within the requested day in Tokyo timezone - expect(tokyoTime.year).toBe(2025); - expect(tokyoTime.month).toBe(8); - expect(tokyoTime.day).toBe(15); - - console.log( - `🔍 Air Record ${record.id}: UTC ${record.created_at} → Tokyo ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` - ); - }); - - console.log( - `✅ VERIFIED: ${airData.length} air quality records with proper timezone conversion` - ); - } else { - console.warn('⚠️ No air quality data found for test date, skipping validation'); - } - }, 10000); - }); - - describe('Report API Error Handling', () => { - it('should handle invalid date ranges gracefully', async () => { - // Arrange - Invalid date range - const startDate = '2025-12-31'; - const endDate = '2025-01-01'; // End before start - - // Act - const pdfResponse = await testPDFEndpoint(trafficDevEui, startDate, endDate, timezone); - - if (pdfResponse === null) return; - - // Assert - Should either return an error or handle gracefully - if (pdfResponse.status !== 200) { - expect(pdfResponse.status).toBeGreaterThanOrEqual(400); - console.log(`✅ VERIFIED: Invalid date range handled with status ${pdfResponse.status}`); - } - }, 10000); - - it('should handle non-existent device gracefully', async () => { - // Arrange - const fakeDevEui = 'NONEXISTENT123456789'; - const startDate = '2025-08-01'; - const endDate = '2025-08-01'; - - // Act - const pdfResponse = await testPDFEndpoint(fakeDevEui, startDate, endDate, timezone); - - if (pdfResponse === null) return; - - // Assert - Should return appropriate error - expect(pdfResponse.status).toBeGreaterThanOrEqual(400); - console.log(`✅ VERIFIED: Non-existent device handled with status ${pdfResponse.status}`); - }, 10000); - }); -}); diff --git a/src/lib/tests/ReportTimezone.test.ts b/src/lib/tests/ReportTimezone.test.ts deleted file mode 100644 index 1dc19db7..00000000 --- a/src/lib/tests/ReportTimezone.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { DateTime } from 'luxon'; -import { DeviceDataService } from '../services/DeviceDataService'; -import type { SupabaseClient } from '@supabase/supabase-js'; - -/** - * CRITICAL REPORT TIMEZONE TESTS - * - * These tests ensure that report data loading and PDF generation handle - * timezone conversion correctly for both created_at (non-traffic) and - * traffic_hour (traffic) fields. - * - * Key requirements: - * - Report data should convert timestamps to user timezone - * - Both traffic and non-traffic devices should work correctly - * - PDF generation should use proper timezone-converted data - */ - -describe('Report Data Timezone Tests', () => { - let deviceDataService: DeviceDataService; - let mockSupabase: SupabaseClient; - - beforeEach(() => { - // Mock Supabase client with report-specific methods - mockSupabase = { - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { - cw_device_type: { data_table_v2: 'cw_air_data' } - }, - error: null - }) - }) - }) - }) - }), - rpc: vi.fn() - } as any; - - deviceDataService = new DeviceDataService(mockSupabase); - }); - - describe('getDeviceDataForReport timezone handling', () => { - it('should pass correct timezone parameters to stored procedure', async () => { - // Arrange - const mockRpcResponse = [ - { - id: 1, - dev_eui: '110110145241600107', - created_at: '2025-08-01T00:00:00+09:00', // Tokyo time - temperature_c: 25.5, - humidity: 60.2 - } - ]; - - (mockSupabase.rpc as any).mockResolvedValue({ - data: mockRpcResponse, - error: null - }); - - const startDate = new Date('2025-08-01T00:00:00.000Z'); // UTC - const endDate = new Date('2025-08-01T23:59:59.999Z'); // UTC - const timezone = 'Asia/Tokyo'; - - // Act - const result = await deviceDataService.getDeviceDataForReport({ - devEui: '110110145241600107', - startDate, - endDate, - timezone, - intervalMinutes: 30 - }); - - // Assert - expect(mockSupabase.rpc).toHaveBeenCalledWith( - 'get_filtered_device_report_data_multi_v2', - expect.objectContaining({ - p_dev_id: '110110145241600107', - p_start_time: startDate, - p_end_time: endDate, - p_interval_minutes: 30, - p_timezone: timezone - }) - ); - - expect(result).toHaveLength(1); - expect(result[0].dev_eui).toBe('110110145241600107'); - }); - - it('should handle report with no data gracefully', async () => { - // Arrange - (mockSupabase.rpc as any).mockResolvedValue({ - data: [], - error: null - }); - - // Act - const result = await deviceDataService.getDeviceDataForReport({ - devEui: '110110145241600107', - startDate: new Date('2025-08-01'), - endDate: new Date('2025-08-01'), - timezone: 'Asia/Tokyo', - intervalMinutes: 30 - }); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('error'); - expect(result[0].error).toBe('No data found for the specified date range'); - }); - - it('should load alert points from device reports', async () => { - // Arrange - const mockReportsData = [ - { - report_id: 'test-report-1', - report_alert_points: [ - { - data_point_key: 'temperature_c', - operator: '>', - value: 30.0, - min: null, - max: null - }, - { - data_point_key: 'humidity', - operator: 'BETWEEN', - value: null, - min: 40.0, - max: 80.0 - } - ] - } - ]; - - // Mock reports query - const mockFrom = vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue({ - data: mockReportsData, - error: null - }) - }) - }) - }); - - (mockSupabase.from as any).mockImplementation((table: string) => { - if (table === 'reports') { - return mockFrom(); - } - // Default device type lookup - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { cw_device_type: { data_table_v2: 'cw_air_data' } }, - error: null - }) - }) - }) - }; - }); - - (mockSupabase.rpc as any).mockResolvedValue({ - data: [{ id: 1, dev_eui: '110110145241600107', temperature_c: 25.5 }], - error: null - }); - - // Act - await deviceDataService.getDeviceDataForReport({ - devEui: '110110145241600107', - startDate: new Date('2025-08-01'), - endDate: new Date('2025-08-01'), - timezone: 'Asia/Tokyo', - intervalMinutes: 30 - }); - - // Assert - expect(mockSupabase.rpc).toHaveBeenCalledWith( - 'get_filtered_device_report_data_multi_v2', - expect.objectContaining({ - p_columns: ['temperature_c', 'humidity'], - p_ops: ['>', 'BETWEEN'], - p_mins: [30.0, 40.0], - p_maxs: [null, 80.0], - p_timezone: 'Asia/Tokyo' - }) - ); - }); - - it('should handle database errors gracefully', async () => { - // Arrange - (mockSupabase.rpc as any).mockResolvedValue({ - data: null, - error: { message: 'Database connection failed' } - }); - - // Act & Assert - await expect( - deviceDataService.getDeviceDataForReport({ - devEui: '110110145241600107', - startDate: new Date('2025-08-01'), - endDate: new Date('2025-08-01'), - timezone: 'Asia/Tokyo', - intervalMinutes: 30 - }) - ).rejects.toThrow('Error fetching report data: Database connection failed'); - }); - }); - - describe('Report data timezone conversion validation', () => { - it('should validate timestamp formats in report data', () => { - // Arrange - Sample report data with different timestamp formats - const reportData = [ - { - id: 1, - created_at: '2025-08-01T00:00:00+09:00', // Tokyo timezone - temperature_c: 25.5 - }, - { - id: 2, - created_at: '2025-08-01T01:00:00+09:00', // Tokyo timezone - temperature_c: 26.0 - } - ]; - - // Act - Parse timestamps and convert to UTC - const parsedData = reportData.map((record) => { - const dt = DateTime.fromISO(record.created_at); - return { - ...record, - utc_time: dt.toUTC().toISO(), - tokyo_time: dt.setZone('Asia/Tokyo').toISO(), - is_valid: dt.isValid - }; - }); - - // Assert - parsedData.forEach((record) => { - expect(record.is_valid).toBe(true); - expect(record.utc_time).toMatch(/Z$/); // Should end with Z for UTC - expect(record.tokyo_time).toContain('+09:00'); // Should have Tokyo offset - }); - - // Validate specific conversion - const firstRecord = parsedData[0]; - expect(firstRecord.utc_time).toBe('2025-07-31T15:00:00.000Z'); // Tokyo midnight = July 31 3PM UTC - }); - - it('should handle traffic data timestamps correctly in reports', () => { - // Arrange - Traffic data uses traffic_hour field instead of created_at - const trafficReportData = [ - { - id: 35976, - traffic_hour: '2025-07-31T15:00:00+00:00', // UTC time - people_count: 0, - car_count: 6 - } - ]; - - // Act - Convert to Tokyo timezone for display - const convertedData = trafficReportData.map((record) => { - const dt = DateTime.fromISO(record.traffic_hour, { zone: 'UTC' }); - return { - ...record, - tokyo_display: dt.setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss'), - tokyo_iso: dt.setZone('Asia/Tokyo').toISO() - }; - }); - - // Assert - const firstRecord = convertedData[0]; - expect(firstRecord.tokyo_display).toBe('2025-08-01 00:00:00'); // Should be midnight Tokyo - expect(firstRecord.tokyo_iso).toContain('2025-08-01T00:00:00'); - expect(firstRecord.tokyo_iso).toContain('+09:00'); - }); - - it('should validate date range boundaries for reports', () => { - // Arrange - Test August 1st midnight Tokyo conversion - const timezone = 'Asia/Tokyo'; - const dateParam = '2025-08-01'; - - // Act - Create DateTime objects in user timezone (same as API logic) - const startDt = DateTime.fromISO(dateParam + 'T00:00:00', { zone: timezone }).startOf('day'); - const endDt = DateTime.fromISO(dateParam + 'T23:59:59.000', { zone: timezone }); - - // Convert to UTC for database query - const utcStart = startDt.toUTC(); - const utcEnd = endDt.toUTC(); - - // Assert - expect(utcStart.toISO()).toBe('2025-07-31T15:00:00.000Z'); // July 31 3PM UTC - expect(utcEnd.toISO()).toBe('2025-08-01T14:59:59.000Z'); // August 1 2:59PM UTC - - // Validate the range covers full day in Tokyo timezone - const rangeDuration = utcEnd.diff(utcStart, 'hours').hours; - expect(rangeDuration).toBeCloseTo(23.999, 2); // Should be almost 24 hours - }); - }); - - describe('PDF generation timezone handling', () => { - it('should prepare data correctly for PDF charts with timezone conversion', () => { - // Arrange - Sample report data - const reportData = [ - { id: 1, created_at: '2025-08-01T00:00:00+09:00', temperature_c: 25.5, humidity: 60 }, - { id: 2, created_at: '2025-08-01T01:00:00+09:00', temperature_c: 26.0, humidity: 58 }, - { id: 3, created_at: '2025-08-01T02:00:00+09:00', temperature_c: 24.8, humidity: 62 } - ]; - - // Act - Convert for chart display (same logic as PDF generation) - const chartData = reportData.map((record) => { - const dt = DateTime.fromISO(record.created_at); - return { - x: dt.setZone('Asia/Tokyo').toFormat('HH:mm'), // Display in Tokyo time - temperature: record.temperature_c, - humidity: record.humidity - }; - }); - - // Assert - expect(chartData[0].x).toBe('00:00'); // Midnight Tokyo - expect(chartData[1].x).toBe('01:00'); // 1 AM Tokyo - expect(chartData[2].x).toBe('02:00'); // 2 AM Tokyo - - // Data should be sequential and logical - expect(chartData).toHaveLength(3); - chartData.forEach((point) => { - expect(point.temperature).toBeGreaterThan(20); - expect(point.humidity).toBeGreaterThan(50); - }); - }); - - it('should handle empty report data for PDF generation', () => { - // Arrange - const emptyReportData: any[] = []; - - // Act - Process empty data (same as PDF generation logic) - const chartData = emptyReportData.map((record) => ({ - x: DateTime.fromISO(record.created_at).setZone('Asia/Tokyo').toFormat('HH:mm'), - temperature: record.temperature_c - })); - - // Assert - expect(chartData).toHaveLength(0); - }); - - it('should validate PDF metadata with correct timezone information', () => { - // Arrange - const timezone = 'Asia/Tokyo'; - const startDate = '2025-08-01'; - const endDate = '2025-08-31'; - - // Act - Generate PDF metadata (same as PDF generation) - const tokyoNow = DateTime.now().setZone(timezone); - const offsetMinutes = tokyoNow.offset; - const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60); - const offsetMins = Math.abs(offsetMinutes) % 60; - const offsetSign = offsetMinutes >= 0 ? '+' : '-'; - const formattedOffset = `${offsetSign}${offsetHours.toString().padStart(2, '0')}${offsetMins.toString().padStart(2, '0')}`; - - const pdfMetadata = { - title: `Device Report - ${startDate} to ${endDate}`, - generatedAt: tokyoNow.toFormat('yyyy-MM-dd HH:mm:ss'), - timezone: timezone, - dateRange: `${startDate} - ${endDate}`, - utcOffset: formattedOffset - }; - - // Assert - expect(pdfMetadata.timezone).toBe('Asia/Tokyo'); - expect(pdfMetadata.utcOffset).toBe('+0900'); - expect(pdfMetadata.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); - expect(pdfMetadata.title).toContain(startDate); - }); - }); - - describe('Alert point processing with timezone awareness', () => { - it('should process alert points correctly with timezone-converted data', () => { - // Arrange - const reportData = [ - { id: 1, created_at: '2025-08-01T14:00:00+09:00', temperature_c: 35.0 }, // Hot afternoon - { id: 2, created_at: '2025-08-01T02:00:00+09:00', temperature_c: 15.0 }, // Cool night - { id: 3, created_at: '2025-08-01T10:00:00+09:00', temperature_c: 25.0 } // Normal morning - ]; - - const alertPoints = [ - { data_point_key: 'temperature_c', operator: '>', value: 30.0, threshold: 'HIGH' }, - { data_point_key: 'temperature_c', operator: '<', value: 20.0, threshold: 'LOW' } - ]; - - // Act - Process alerts (same logic as report generation) - const alertResults = reportData.map((record) => { - const dt = DateTime.fromISO(record.created_at); - const alerts = alertPoints.filter((alert) => { - if (alert.operator === '>' && record.temperature_c > alert.value) return true; - if (alert.operator === '<' && record.temperature_c < alert.value) return true; - return false; - }); - - return { - ...record, - tokyo_time: dt.setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss'), - alerts: alerts, - alert_count: alerts.length - }; - }); - - // Assert - expect(alertResults[0].alerts).toHaveLength(1); // Hot afternoon should trigger HIGH alert - expect(alertResults[0].alerts[0].threshold).toBe('HIGH'); - expect(alertResults[0].tokyo_time).toBe('2025-08-01 14:00:00'); - - expect(alertResults[1].alerts).toHaveLength(1); // Cool night should trigger LOW alert - expect(alertResults[1].alerts[0].threshold).toBe('LOW'); - expect(alertResults[1].tokyo_time).toBe('2025-08-01 02:00:00'); - - expect(alertResults[2].alerts).toHaveLength(0); // Normal morning should trigger no alerts - }); - }); -}); diff --git a/src/lib/tests/TrafficDataIntegration.test.ts b/src/lib/tests/TrafficDataIntegration.test.ts deleted file mode 100644 index 61132680..00000000 --- a/src/lib/tests/TrafficDataIntegration.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, beforeAll } from 'vitest'; -import { createClient } from '@supabase/supabase-js'; -import { DateTime } from 'luxon'; - -/** - * CRITICAL TRAFFIC DATA INTEGRATION TESTS - * - * These tests verify the complete timezone conversion flow from API input - * to database query results. The key validation is that August 1st midnight - * Tokyo time correctly returns the record with ID 35976. - * - * This ensures the "very very backwards" traffic patterns bug stays fixed. - */ - -describe('Traffic Data Integration Tests', () => { - let supabase: ReturnType; - const devEui = '110110145241600107'; - const timezone = 'Asia/Tokyo'; - - beforeAll(() => { - // Use environment variables if available, otherwise use test credentials - const supabaseUrl = - process.env.PUBLIC_SUPABASE_URL || 'https://dpaoqrcfswnzknixwkll.supabase.co'; - const supabaseKey = - process.env.PUBLIC_SUPABASE_ANON_KEY || - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwYW9xcmNmc3duemtuaXh3a2xsIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzc1MDAwMzAsImV4cCI6MTk5MzA3NjAzMH0.fYA8IMcfuO0g42prGg3h3q_DtlwvWLKfd6nIs5dqAf0'; - - supabase = createClient(supabaseUrl, supabaseKey); - }); - - // Helper function - same logic as DeviceDataService - function convertUserTimezoneToUTC(date: Date, timezone: string): Date { - if (timezone === 'UTC') { - return date; - } - - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hour = date.getHours(); - const minute = date.getMinutes(); - const second = date.getSeconds(); - - const dt = DateTime.fromObject( - { - year, - month, - day, - hour, - minute, - second - }, - { zone: timezone } - ); - - return dt.toUTC().toJSDate(); - } - - describe('Critical Timezone Validation', () => { - it('should find record ID 35976 for August 1st midnight Tokyo (CRITICAL TEST)', async () => { - // Arrange - Exact same logic as API endpoints - const startDateParam = '2025-08-01'; - const endDateParam = '2025-08-01'; // Same day to narrow down search - - // Create DateTime in Tokyo timezone (same as fixed API logic) - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); - - // Convert to UTC for database query - const startDate = startDt.toJSDate(); - const endDate = endDt.toJSDate(); - const utcStartDate = convertUserTimezoneToUTC(startDate, timezone); - const utcEndDate = convertUserTimezoneToUTC(endDate, timezone); - - // Act - Query database exactly like DeviceDataService - const { data, error } = await supabase - .from('cw_traffic2') - .select('id, dev_eui, traffic_hour, people_count, car_count') - .eq('dev_eui', devEui) - .gte('traffic_hour', utcStartDate.toISOString()) - .lte('traffic_hour', utcEndDate.toISOString()) - .order('traffic_hour', { ascending: true }); // Ascending to get midnight first - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - expect(Array.isArray(data)).toBe(true); - - // Find the specific record we verified works - const midnightRecord = data?.find((record) => record.id === 35976); - expect(midnightRecord).toBeDefined(); - expect(midnightRecord?.dev_eui).toBe(devEui); - - // Verify the timestamp conversion - if (midnightRecord) { - const utcTime = DateTime.fromISO(midnightRecord.traffic_hour); - const tokyoTime = utcTime.setZone(timezone); - - // Should be August 1st midnight Tokyo - expect(tokyoTime.year).toBe(2025); - expect(tokyoTime.month).toBe(8); // August - expect(tokyoTime.day).toBe(1); - expect(tokyoTime.hour).toBe(0); // Midnight - expect(tokyoTime.minute).toBe(0); - - console.log( - `✅ VERIFIED: Record ${midnightRecord.id} - UTC: ${midnightRecord.traffic_hour}, Tokyo: ${tokyoTime.toFormat('yyyy-MM-dd HH:mm:ss')}` - ); - } - }, 10000); // 10 second timeout for network request - - it('should return complete August 2025 dataset', async () => { - // Arrange - Full August range - const startDateParam = '2025-08-01'; - const endDateParam = '2025-08-31'; - - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); - - const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); - const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); - - // Act - const { data, error, count } = await supabase - .from('cw_traffic2') - .select('id, traffic_hour', { count: 'exact' }) - .eq('dev_eui', devEui) - .gte('traffic_hour', utcStartDate.toISOString()) - .lte('traffic_hour', utcEndDate.toISOString()); - - // Assert - expect(error).toBeNull(); - expect(count).toBeGreaterThan(700); // Should be ~744 records (31 days * 24 hours) - expect(count).toBeLessThan(800); // Reasonable upper bound - - console.log(`✅ VERIFIED: Found ${count} records for August 2025`); - }, 15000); - - it('should validate timezone conversion accuracy across date range', async () => { - // Arrange - Test several key dates - const testDates = [ - '2025-08-01', // Month start - '2025-08-15', // Mid-month - '2025-08-31' // Month end - ]; - - for (const dateParam of testDates) { - // Create midnight Tokyo time - const tokyoDt = DateTime.fromISO(dateParam + 'T00:00:00', { zone: timezone }); - const utcDate = convertUserTimezoneToUTC(tokyoDt.toJSDate(), timezone); - - // Query for records around this time (±1 hour for safety) - const { data, error } = await supabase - .from('cw_traffic2') - .select('id, traffic_hour') - .eq('dev_eui', devEui) - .gte('traffic_hour', new Date(utcDate.getTime() - 3600000).toISOString()) // -1 hour - .lte('traffic_hour', new Date(utcDate.getTime() + 3600000).toISOString()) // +1 hour - .limit(5); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - - if (data && data.length > 0) { - // Verify at least one record exists near the expected time - const hasNearbyRecord = data.some((record) => { - const recordUtc = new Date(record.traffic_hour); - const timeDiff = Math.abs(recordUtc.getTime() - utcDate.getTime()); - return timeDiff < 3600000; // Within 1 hour - }); - - expect(hasNearbyRecord).toBe(true); - console.log(`✅ VERIFIED: Found records near ${dateParam} midnight Tokyo`); - } - } - }, 20000); - }); - - describe('Data Quality Validation', () => { - it('should verify realistic traffic patterns (not backwards)', async () => { - // Arrange - Get a sample day of data - const sampleDate = '2025-08-15'; - const startDt = DateTime.fromISO(sampleDate + 'T00:00:00', { zone: timezone }).startOf('day'); - const endDt = DateTime.fromISO(sampleDate + 'T23:59:59', { zone: timezone }).endOf('day'); - - const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); - const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); - - // Act - const { data, error } = await supabase - .from('cw_traffic2') - .select('traffic_hour, people_count, car_count') - .eq('dev_eui', devEui) - .gte('traffic_hour', utcStartDate.toISOString()) - .lte('traffic_hour', utcEndDate.toISOString()) - .order('traffic_hour', { ascending: true }); - - // Assert - expect(error).toBeNull(); - expect(data).toBeDefined(); - - if (data && data.length > 0) { - // Convert timestamps back to Tokyo time for analysis - const tokyoData = data.map((record) => { - const utcTime = DateTime.fromISO(record.traffic_hour); - const tokyoTime = utcTime.setZone(timezone); - return { - hour: tokyoTime.hour, - people: record.people_count || 0, - cars: record.car_count || 0 - }; - }); - - // Calculate average traffic for night (0-5 AM) vs day (9 AM - 5 PM) - const nightTraffic = tokyoData - .filter((d) => d.hour >= 0 && d.hour <= 5) - .reduce((sum, d) => sum + d.people + d.cars, 0); - - const dayTraffic = tokyoData - .filter((d) => d.hour >= 9 && d.hour <= 17) - .reduce((sum, d) => sum + d.people + d.cars, 0); - - // Realistic pattern: day traffic should be higher than night traffic - expect(dayTraffic).toBeGreaterThan(nightTraffic); - - console.log( - `✅ VERIFIED: Realistic traffic pattern - Day: ${dayTraffic}, Night: ${nightTraffic}` - ); - } - }, 15000); - - it('should validate data completeness for full month', async () => { - // Arrange - const startDateParam = '2025-08-01'; - const endDateParam = '2025-08-31'; - - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: timezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: timezone }).endOf('day'); - - const utcStartDate = convertUserTimezoneToUTC(startDt.toJSDate(), timezone); - const utcEndDate = convertUserTimezoneToUTC(endDt.toJSDate(), timezone); - - // Act - const { data, error, count } = await supabase - .from('cw_traffic2') - .select('traffic_hour', { count: 'exact' }) - .eq('dev_eui', devEui) - .gte('traffic_hour', utcStartDate.toISOString()) - .lte('traffic_hour', utcEndDate.toISOString()); - - // Assert - expect(error).toBeNull(); - expect(count).toBeDefined(); - - // August has 31 days * 24 hours = 744 expected records - const expectedRecords = 31 * 24; - const completeness = (count! / expectedRecords) * 100; - - expect(completeness).toBeGreaterThan(90); // At least 90% data completeness - - console.log( - `✅ VERIFIED: Data completeness: ${completeness.toFixed(1)}% (${count}/${expectedRecords} records)` - ); - }, 15000); - }); -}); diff --git a/src/lib/tests/mocks/MockSupabase.ts b/src/lib/tests/mocks/MockSupabase.ts deleted file mode 100644 index 1fe7bfa8..00000000 --- a/src/lib/tests/mocks/MockSupabase.ts +++ /dev/null @@ -1,151 +0,0 @@ -// @ts-nocheck -import type { SupabaseClient, PostgrestResponse } from '@supabase/supabase-js'; - -/** - * Creates a mock Supabase client for testing - * @param mockData Mock data to return from queries - * @returns A mock Supabase client - */ -export function createMockSupabaseClient(mockData: any = {}): SupabaseClient { - const mockFrom = (tableName: string) => { - const tableData = mockData[tableName] || []; - - // Build the query object with chainable methods - const query = { - select: (columns: string = '*') => { - return { - ...query, - eq: (column: string, value: any) => { - const filteredData = tableData.filter((item: any) => item[column] === value); - return { - ...query, - single: () => mockResponse(filteredData[0]), - limit: () => ({ - single: () => mockResponse(filteredData[0]) - }), - order: () => ({ - limit: () => ({ - single: () => mockResponse(filteredData[0]) - }) - }), - data: filteredData - }; - }, - gte: () => query, - lte: () => query, - order: () => query, - limit: () => query, - single: () => mockResponse(tableData[0]), - data: tableData - }; - }, - insert: (data: any) => { - const newItem = { ...data }; - return { - select: () => ({ - single: () => mockResponse(newItem) - }) - }; - }, - update: (data: any) => { - return { - eq: (column: string, value: any) => { - const index = tableData.findIndex((item: any) => item[column] === value); - const updatedItem = index >= 0 ? { ...tableData[index], ...data } : null; - return { - select: () => ({ - single: () => mockResponse(updatedItem) - }) - }; - } - }; - }, - delete: () => { - return { - eq: () => mockResponse(null) - }; - } - }; - - return query; - }; - - const mockResponse = (data: T | null): PostgrestResponse => { - return { - data, - error: null, - count: data ? 1 : 0, - status: 200, - statusText: 'OK' - }; - }; - - // Return a partial mock implementation of SupabaseClient - return { - from: mockFrom - } as unknown as SupabaseClient; -} - -/** - * Creates a mock for the Supabase client that simulates errors - * @param errorCode PostgreSQL error code to simulate - * @param errorMessage Error message to return - * @returns A mock Supabase client that returns errors - */ -export function createErrorMockSupabaseClient( - errorCode: string = '42P01', - errorMessage: string = 'Mock Error' -): SupabaseClient { - const mockErrorResponse = (): PostgrestResponse => { - return { - data: null, - error: { - code: errorCode, - message: errorMessage, - details: '', - hint: '' - }, - count: null, - status: 400, - statusText: 'Bad Request' - }; - }; - - const mockFrom = () => { - return { - select: () => ({ - eq: () => ({ - single: () => mockErrorResponse(), - limit: () => ({ - single: () => mockErrorResponse() - }), - order: () => ({ - limit: () => ({ - single: () => mockErrorResponse() - }) - }) - }), - single: () => mockErrorResponse() - }), - insert: () => ({ - select: () => ({ - single: () => mockErrorResponse() - }) - }), - update: () => ({ - eq: () => ({ - select: () => ({ - single: () => mockErrorResponse() - }) - }) - }), - delete: () => ({ - eq: () => mockErrorResponse() - }) - }; - }; - - return { - from: mockFrom - } as unknown as SupabaseClient; -} diff --git a/src/lib/tests/repositories/BaseRepository.test.ts b/src/lib/tests/repositories/BaseRepository.test.ts deleted file mode 100644 index 63e20ed6..00000000 --- a/src/lib/tests/repositories/BaseRepository.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { BaseRepository } from '../../repositories/BaseRepository'; -import { createMockSupabaseClient } from '../mocks/MockSupabase'; -import { ErrorHandlingService } from '../../errors/ErrorHandlingService'; - -interface Item { id: number; name: string } -class TestRepo extends BaseRepository { - protected tableName = 'items'; - protected primaryKey = 'id'; - protected entityName = 'Item'; -} - -describe('BaseRepository', () => { - const mockData = { items: [{ id: 1, name: 'A' }] }; - const repo = new TestRepo(createMockSupabaseClient(mockData), new ErrorHandlingService()); - - it('findById returns record', async () => { - const result = await repo.findById(1); - expect(result).toEqual({ id: 1, name: 'A' }); - }); - - it('findAll returns all records', async () => { - const result = await repo.findAll(); - expect(result).toHaveLength(1); - }); -}); diff --git a/src/lib/tests/services/DeviceService.test.ts b/src/lib/tests/services/DeviceService.test.ts deleted file mode 100644 index b5eaea36..00000000 --- a/src/lib/tests/services/DeviceService.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { DeviceService } from '../../services/DeviceService'; -import { DeviceRepository } from '../../repositories/DeviceRepository'; -import { createMockSupabaseClient, createErrorMockSupabaseClient } from '../mocks/MockSupabase'; -import { ErrorHandlingService } from '../../errors/ErrorHandlingService'; -import { NotFoundError } from '../../errors/SpecificErrors'; -import type { Device } from '../../models/Device'; -import type { CreateDeviceDto, UpdateDeviceDto } from '../../dtos/DeviceDto'; - -describe('DeviceService', () => { - // Mock data - const mockDevices: Device[] = [ - { - dev_eui: 'device-001', - name: 'Test Device 1', - type: 1, - location_id: 100, - lat: 40.7128, - long: -74.006 - }, - { - dev_eui: 'device-002', - name: 'Test Device 2', - type: 2, - location_id: 101 - } - ]; - - // Mock implementations - let deviceService: DeviceService; - let mockSupabase: ReturnType; - let deviceRepository: DeviceRepository; - let errorHandlingService: ErrorHandlingService; - - beforeEach(() => { - // Create test mocks - mockSupabase = createMockSupabaseClient({ - cw_devices: mockDevices - }); - - errorHandlingService = new ErrorHandlingService(); - vi.spyOn(errorHandlingService, 'logError'); - - deviceRepository = new DeviceRepository(mockSupabase, errorHandlingService); - deviceService = new DeviceService(deviceRepository); - }); - - describe('getAllDevices', () => { - it('should return all devices', async () => { - const devices = await deviceService.getAllDevices(); - expect(devices).toHaveLength(2); - expect(devices[0].dev_eui).toBe('device-001'); - expect(devices[1].dev_eui).toBe('device-002'); - }); - - it('should handle errors when retrieving devices', async () => { - const errorSupabase = createErrorMockSupabaseClient(); - const errorRepo = new DeviceRepository(errorSupabase, errorHandlingService); - const errorService = new DeviceService(errorRepo); - - const result = await errorService.getAllDevices(); - expect(result).toEqual([]); - }); - }); - - describe('getDeviceByEui', () => { - it('should return a device by EUI', async () => { - const device = await deviceService.getDeviceByEui('device-001'); - expect(device).not.toBeNull(); - expect(device?.dev_eui).toBe('device-001'); - expect(device?.name).toBe('Test Device 1'); - }); - - it('should return null for non-existent device', async () => { - vi.spyOn(deviceRepository, 'findById').mockResolvedValue(null); - - const device = await deviceService.getDeviceByEui('non-existent-device'); - expect(device).toBeNull(); - }); - }); - - describe('createDevice', () => { - it('should create a new device', async () => { - const newDevice: CreateDeviceDto = { - dev_eui: 'new-device', - name: 'New Test Device', - type: 3, - location_id: 102 - }; - - vi.spyOn(deviceRepository, 'create').mockResolvedValue({ - ...newDevice, - lat: null, - long: null, - serial_number: null, - upload_interval: null - } as Device); - - const createdDevice = await deviceService.createDevice(newDevice); - expect(createdDevice.dev_eui).toBe('new-device'); - expect(createdDevice.name).toBe('New Test Device'); - expect(deviceRepository.create).toHaveBeenCalledWith(newDevice); - }); - }); - - describe('updateDevice', () => { - it('should update an existing device', async () => { - const updateData: UpdateDeviceDto = { - name: 'Updated Device Name', - lat: 41.8781, - long: -87.6298 - }; - - vi.spyOn(deviceRepository, 'update').mockResolvedValue({ - ...mockDevices[0], - name: updateData.name, - lat: updateData.lat, - long: updateData.long - }); - - const updatedDevice = await deviceService.updateDevice('device-001', updateData); - expect(updatedDevice).not.toBeNull(); - expect(updatedDevice?.name).toBe('Updated Device Name'); - expect(updatedDevice?.lat).toBe(41.8781); - expect(deviceRepository.update).toHaveBeenCalledWith('device-001', updateData); - }); - - it('should throw NotFoundError when device does not exist', async () => { - const updateData: UpdateDeviceDto = { name: 'Invalid Update' }; - - vi.spyOn(deviceRepository, 'update').mockImplementation(() => { - throw new NotFoundError('Device not found with identifier: non-existent-device'); - }); - - await expect(deviceService.updateDevice('non-existent-device', updateData)).rejects.toThrow( - NotFoundError - ); - }); - }); - - describe('deleteDevice', () => { - it('should delete an existing device', async () => { - vi.spyOn(deviceRepository, 'delete').mockResolvedValue(true); - - const result = await deviceService.deleteDevice('device-001'); - expect(result).toBe(true); - expect(deviceRepository.delete).toHaveBeenCalledWith('device-001'); - }); - }); -}); diff --git a/src/lib/tests/services/LocationService.test.ts b/src/lib/tests/services/LocationService.test.ts deleted file mode 100644 index a576ac33..00000000 --- a/src/lib/tests/services/LocationService.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { LocationService } from '../../services/LocationService'; -import { LocationRepository } from '../../repositories/LocationRepository'; -import { ErrorHandlingService } from '../../errors/ErrorHandlingService'; -import type { Location } from '../../models/Location'; -import type { LocationDto } from '../../dtos/LocationDto'; - -describe('LocationService', () => { - let locationService: LocationService; - let mockRepository: any; - let mockDeviceRepository: any; - let errorHandlingService: ErrorHandlingService; - - const mockLocations: Location[] = [ - { - id: '1', - name: 'Greenhouse 1', - coordinates: { lat: 35.6812, lng: 139.7671 }, - user_id: 'user_123', - created_at: '2025-05-01T10:00:00Z' - }, - { - id: '2', - name: 'Field A', - coordinates: { lat: 35.6813, lng: 139.7675 }, - user_id: 'user_123', - created_at: '2025-05-01T11:00:00Z' - } - ]; - - beforeEach(() => { - mockRepository = { - findAll: vi.fn(), - findById: vi.fn(), - findByUserId: vi.fn(), - findByOwnerId: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn() - }; - mockDeviceRepository = { - findByLocation: vi.fn(), - addUserToDevice: vi.fn(), - updateDevicePermission: vi.fn(), - findDeviceOwner: vi.fn(), - removeUserFromDevice: vi.fn() - }; - errorHandlingService = new ErrorHandlingService(); - locationService = new LocationService( - mockRepository, - mockDeviceRepository, - errorHandlingService - ); - }); - - describe('getAllLocations', () => { - it('should return all locations', async () => { - mockRepository.findAll.mockResolvedValue(mockLocations); - - const result = await locationService.getAllLocations(); - - expect(mockRepository.findAll).toHaveBeenCalled(); - expect(result).toHaveLength(2); - expect(result[0].name).toBe('Greenhouse 1'); - expect(result[1].name).toBe('Field A'); - }); - - it('should handle errors when getting all locations', async () => { - mockRepository.findAll.mockRejectedValue(new Error('Database error')); - - await expect(locationService.getAllLocations()).rejects.toThrow('Database error'); - }); - }); - - describe('getLocationById', () => { - it('should return a location by id', async () => { - mockRepository.findById.mockResolvedValue(mockLocations[0]); - - const result = await locationService.getLocationById('1'); - - expect(mockRepository.findById).toHaveBeenCalledWith('1'); - expect(result).toBeDefined(); - expect(result.id).toBe('1'); - expect(result.name).toBe('Greenhouse 1'); - }); - - it('should return null when location does not exist', async () => { - mockRepository.findById.mockResolvedValue(null); - - const result = await locationService.getLocationById('999'); - - expect(result).toBeNull(); - }); - }); - - describe('getLocationsByOwner', () => { - it('should return locations for a user', async () => { - mockRepository.findByOwnerId.mockResolvedValue(mockLocations); - - const result = await locationService.getLocationsByOwner('user_123'); - - expect(mockRepository.findByOwnerId).toHaveBeenCalledWith('user_123'); - expect(result).toHaveLength(2); - expect(result[0].user_id).toBe('user_123'); - }); - }); - - describe('createLocation', () => { - it('should create a new location', async () => { - const locationDto: LocationDto = { - name: 'Greenhouse 2', - coordinates: { lat: 35.682, lng: 139.768 }, - user_id: 'user_123' - }; - - const createdLocation: Location = { - ...locationDto, - id: '3', - created_at: '2025-05-02T10:00:00Z' - }; - - mockRepository.create.mockResolvedValue(createdLocation); - - const result = await locationService.createLocation(locationDto); - - expect(mockRepository.create).toHaveBeenCalledWith(locationDto); - expect(result).toBeDefined(); - expect(result.id).toBe('3'); - expect(result.name).toBe('Greenhouse 2'); - }); - }); - - describe('updateLocation', () => { - it('should update an existing location', async () => { - const locationDto: LocationDto = { - name: 'Greenhouse 1 Updated', - coordinates: { lat: 35.6812, lng: 139.7671 }, - user_id: 'user_123' - }; - - const updatedLocation: Location = { - ...locationDto, - id: '1', - created_at: '2025-05-01T10:00:00Z' - }; - - mockRepository.update.mockResolvedValue(updatedLocation); - - const result = await locationService.updateLocation('1', locationDto); - - expect(mockRepository.update).toHaveBeenCalledWith('1', locationDto); - expect(result).toBeDefined(); - expect(result.name).toBe('Greenhouse 1 Updated'); - }); - - it('should return null when location to update does not exist', async () => { - mockRepository.update.mockResolvedValue(null); - - const result = await locationService.updateLocation('999', { - name: 'Test', - coordinates: { lat: 0, lng: 0 }, - user_id: 'user_123' - }); - - expect(result).toBeNull(); - }); - }); - - describe('deleteLocation', () => { - it('should delete a location', async () => { - mockRepository.delete.mockResolvedValue({ success: true }); - - const result = await locationService.deleteLocation('1'); - - expect(mockRepository.delete).toHaveBeenCalledWith('1'); - expect(result).toEqual({ success: true }); - }); - }); -}); diff --git a/src/lib/tests/services/ReportTemplateService.test.ts b/src/lib/tests/services/ReportTemplateService.test.ts deleted file mode 100644 index 6950b91c..00000000 --- a/src/lib/tests/services/ReportTemplateService.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ReportTemplateService } from '../../services/ReportTemplateService'; -import type { ReportTemplate } from '../../models/ReportTemplate'; - -describe('ReportTemplateService', () => { - let service: ReportTemplateService; - let repo: any; - const mockReports: ReportTemplate[] = [ - { id: 1, name: 'R1', owner_id: 'user1', created_at: '2024-01-01', dev_eui: null, recipients: null, template: {} } - ]; - - beforeEach(() => { - repo = { - findByOwner: vi.fn().mockResolvedValue(mockReports), - findById: vi.fn().mockResolvedValue(mockReports[0]), - create: vi.fn().mockResolvedValue(mockReports[0]), - update: vi.fn().mockResolvedValue(mockReports[0]), - delete: vi.fn().mockResolvedValue(true) - }; - service = new ReportTemplateService(repo); - }); - - it('returns user reports', async () => { - const res = await service.getUserReports('user1'); - expect(repo.findByOwner).toHaveBeenCalledWith('user1'); - expect(res).toHaveLength(1); - }); - - it('creates report', async () => { - await service.createReport({ name: 'N', template: {}, owner_id: 'user1' } as any); - expect(repo.create).toHaveBeenCalled(); - }); -}); diff --git a/src/lib/tests/services/RuleService.test.ts b/src/lib/tests/services/RuleService.test.ts deleted file mode 100644 index 7941b123..00000000 --- a/src/lib/tests/services/RuleService.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -// @ts-nocheck -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { RuleService } from '../../services/RuleService'; -import { RuleRepository } from '../../repositories/RuleRepository'; -import { createMockSupabaseClient, createErrorMockSupabaseClient } from '../mocks/MockSupabase'; -import { ErrorHandlingService } from '../../errors/ErrorHandlingService'; -import type { Rule, RuleCriteria, RuleWithCriteria } from '../../models/Rule'; - -describe('RuleService', () => { - // Mock data - const mockRules: Rule[] = [ - { - id: 1, - ruleGroupId: 'rule-group-1', - name: 'High Temperature Alert', - description: 'Alert when temperature exceeds threshold', - dev_eui: 'device-001', - profile_id: 'profile-123', - is_active: true, - severity: 'high', - notifier_type: 'notification', - message: 'Temperature is too high!', - created_at: '2025-05-01T10:00:00Z' - }, - { - id: 2, - ruleGroupId: 'rule-group-2', - name: 'Low Moisture Alert', - description: 'Alert when moisture falls below threshold', - dev_eui: 'device-001', - profile_id: 'profile-123', - is_active: true, - severity: 'medium', - notifier_type: 'notification', - message: 'Soil moisture is too low!', - created_at: '2025-05-01T11:00:00Z' - } - ] as Rule[]; - - const mockCriteria: RuleCriteria[] = [ - { - id: 1, - ruleGroupId: 'rule-group-1', - sensor_type: 'temperature', - operator: '>', - value: 30, - unit: 'C', - created_at: '2025-05-01T10:05:00Z' - }, - { - id: 2, - ruleGroupId: 'rule-group-2', - sensor_type: 'moisture', - operator: '<', - value: 20, - unit: '%', - created_at: '2025-05-01T11:05:00Z' - } - ] as RuleCriteria[]; - - // Mock implementations - let ruleService: RuleService; - let mockSupabase: ReturnType; - let ruleRepository: RuleRepository; - let errorHandlingService: ErrorHandlingService; - - beforeEach(() => { - // Create test mocks - mockSupabase = createMockSupabaseClient({ - cw_rules: mockRules, - cw_rule_criteria: mockCriteria - }); - - errorHandlingService = new ErrorHandlingService(); - vi.spyOn(errorHandlingService, 'logError'); - - ruleRepository = new RuleRepository(mockSupabase, errorHandlingService); - ruleService = new RuleService(ruleRepository); - }); - - describe('getRuleById', () => { - it('should return a rule by its ID', async () => { - vi.spyOn(ruleRepository, 'findById').mockResolvedValue(mockRules[0]); - - const rule = await ruleService.getRuleById(1); - expect(rule).not.toBeNull(); - expect(rule?.id).toBe(1); - expect(rule?.name).toBe('High Temperature Alert'); - }); - - it('should return null for non-existent rule ID', async () => { - vi.spyOn(ruleRepository, 'findById').mockResolvedValue(null); - - const rule = await ruleService.getRuleById(999); - expect(rule).toBeNull(); - }); - }); - - describe('getRuleWithCriteria', () => { - it('should return a rule with its associated criteria', async () => { - const mockRuleWithCriteria: RuleWithCriteria = { - ...mockRules[0], - criteria: [mockCriteria[0]] - }; - - vi.spyOn(ruleRepository, 'findRuleWithCriteria').mockResolvedValue(mockRuleWithCriteria); - - const rule = await ruleService.getRuleWithCriteria('rule-group-1'); - expect(rule).not.toBeNull(); - expect(rule?.name).toBe('High Temperature Alert'); - expect(rule?.criteria).toHaveLength(1); - expect(rule?.criteria[0].sensor_type).toBe('temperature'); - }); - }); - - describe('getRulesByDevice', () => { - it('should return rules for a specific device', async () => { - vi.spyOn(ruleRepository, 'findByDevice').mockResolvedValue(mockRules); - - const rules = await ruleService.getRulesByDevice('device-001'); - expect(rules).toHaveLength(2); - expect(rules[0].dev_eui).toBe('device-001'); - }); - }); - - describe('getRulesByProfile', () => { - it('should return rules for a specific profile', async () => { - vi.spyOn(ruleRepository, 'findByProfile').mockResolvedValue(mockRules); - - const rules = await ruleService.getRulesByProfile('profile-123'); - expect(rules).toHaveLength(2); - expect(rules[0].profile_id).toBe('profile-123'); - }); - }); - - describe('getRuleCriteriaByGroup', () => { - it('should return criteria for a specific rule group', async () => { - vi.spyOn(ruleRepository, 'findCriteriaByGroup').mockResolvedValue([mockCriteria[0]]); - - const criteria = await ruleService.getRuleCriteriaByGroup('rule-group-1'); - expect(criteria).toHaveLength(1); - expect(criteria[0].ruleGroupId).toBe('rule-group-1'); - }); - }); - - describe('createRule', () => { - it('should create a new rule', async () => { - const newRule = { - ruleGroupId: 'rule-group-3', - name: 'New Test Rule', - description: 'Test rule description', - dev_eui: 'device-002', - profile_id: 'profile-123', - is_active: true - }; - - vi.spyOn(ruleRepository, 'create').mockResolvedValue({ - id: 3, - ...newRule, - severity: null, - notifier_type: null, - message: null, - created_at: '2025-05-02T12:00:00Z' - } as Rule); - - const createdRule = await ruleService.createRule(newRule); - expect(createdRule.id).toBe(3); - expect(createdRule.name).toBe('New Test Rule'); - expect(ruleRepository.create).toHaveBeenCalledWith(newRule); - }); - }); - - describe('createRuleCriteria', () => { - it('should create a new rule criteria', async () => { - const newCriteria = { - ruleGroupId: 'rule-group-1', - sensor_type: 'humidity', - operator: '>', - value: 80 - }; - - vi.spyOn(ruleRepository, 'createCriteria').mockResolvedValue({ - id: 3, - ...newCriteria, - unit: '%', - created_at: '2025-05-02T12:00:00Z' - } as RuleCriteria); - - const createdCriteria = await ruleService.createRuleCriteria(newCriteria); - expect(createdCriteria.id).toBe(3); - expect(createdCriteria.sensor_type).toBe('humidity'); - expect(ruleRepository.createCriteria).toHaveBeenCalledWith(newCriteria); - }); - }); - - describe('createRuleWithCriteria', () => { - it('should create a rule with associated criteria', async () => { - const newRule = { - ruleGroupId: 'rule-group-4', - name: 'Combined Rule', - description: 'Test combined rule', - dev_eui: 'device-003', - profile_id: 'profile-456', - is_active: true - }; - - const newCriteria = [ - { - sensor_type: 'temperature', - operator: '>', - value: 25, - unit: 'C' - }, - { - sensor_type: 'humidity', - operator: '<', - value: 40, - unit: '%' - } - ]; - - // Mock the rule creation - vi.spyOn(ruleService, 'createRule').mockResolvedValue({ - id: 4, - ...newRule, - severity: null, - notifier_type: null, - message: null, - created_at: '2025-05-02T12:00:00Z' - } as Rule); - - // Mock the criteria creation - vi.spyOn(ruleService, 'createRuleCriteria') - .mockResolvedValueOnce({ - id: 5, - ruleGroupId: newRule.ruleGroupId, - ...newCriteria[0], - created_at: '2025-05-02T12:00:00Z' - } as RuleCriteria) - .mockResolvedValueOnce({ - id: 6, - ruleGroupId: newRule.ruleGroupId, - ...newCriteria[1], - created_at: '2025-05-02T12:00:00Z' - } as RuleCriteria); - - const result = await ruleService.createRuleWithCriteria(newRule, newCriteria); - - expect(result.id).toBe(4); - expect(result.name).toBe('Combined Rule'); - expect(result.criteria).toHaveLength(2); - expect(result.criteria[0].sensor_type).toBe('temperature'); - expect(result.criteria[1].sensor_type).toBe('humidity'); - - expect(ruleService.createRule).toHaveBeenCalledWith(newRule); - expect(ruleService.createRuleCriteria).toHaveBeenCalledTimes(2); - }); - }); - - describe('updateRule', () => { - it('should update an existing rule', async () => { - const updateData = { - name: 'Updated Rule Name', - is_active: false - }; - - vi.spyOn(ruleRepository, 'update').mockResolvedValue({ - ...mockRules[0], - ...updateData - }); - - const updatedRule = await ruleService.updateRule(1, updateData); - expect(updatedRule?.name).toBe('Updated Rule Name'); - expect(updatedRule?.is_active).toBe(false); - expect(ruleRepository.update).toHaveBeenCalledWith(1, updateData); - }); - }); - - describe('deleteRule', () => { - it('should delete a rule and its criteria', async () => { - vi.spyOn(ruleRepository, 'findById').mockResolvedValue(mockRules[0]); - vi.spyOn(ruleRepository, 'deleteCriteriaByGroup').mockResolvedValue(true); - vi.spyOn(ruleRepository, 'delete').mockResolvedValue(true); - - const result = await ruleService.deleteRule(1); - - expect(result).toBe(true); - expect(ruleRepository.deleteCriteriaByGroup).toHaveBeenCalledWith('rule-group-1'); - expect(ruleRepository.delete).toHaveBeenCalledWith(1); - }); - - it('should return true when deleting non-existent rule', async () => { - vi.spyOn(ruleRepository, 'findById').mockResolvedValue(null); - vi.spyOn(ruleRepository, 'deleteCriteriaByGroup').mockResolvedValue(true); - vi.spyOn(ruleRepository, 'delete').mockResolvedValue(true); - - const result = await ruleService.deleteRule(999); - expect(result).toBe(true); - expect(ruleRepository.deleteCriteriaByGroup).not.toHaveBeenCalled(); - expect(ruleRepository.delete).not.toHaveBeenCalled(); - }); - }); - - describe('deleteRuleCriteria', () => { - it('should delete a rule criteria', async () => { - vi.spyOn(ruleRepository, 'deleteCriteria').mockResolvedValue(true); - - const result = await ruleService.deleteRuleCriteria(1); - expect(result).toBe(true); - expect(ruleRepository.deleteCriteria).toHaveBeenCalledWith(1); - }); - }); -}); diff --git a/src/lib/tests/services/SessionService.test.ts b/src/lib/tests/services/SessionService.test.ts deleted file mode 100644 index 557094a4..00000000 --- a/src/lib/tests/services/SessionService.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { SessionService } from '../../services/SessionService'; - -function createAuthMock(session: any = null, user: any = null) { - return { - auth: { - getSession: vi.fn().mockResolvedValue({ data: { session } }), - getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }) - } - } as any; -} - -describe('SessionService', () => { - it('returns nulls when no session', async () => { - const svc = new SessionService(createAuthMock()); - const result = await svc.getSafeSession(); - expect(result).toEqual({ session: null, user: null }); - }); - - it('returns session and user when present', async () => { - const session = { id: 's1' } as any; - const user = { id: 'u1', email: 'a@b.c' } as any; - const svc = new SessionService(createAuthMock(session, user)); - const result = await svc.getSafeSession(); - expect(result.session).toEqual(session); - expect(result.user).toEqual(user); - }); -}); diff --git a/src/lib/theme/chartPalette.ts b/src/lib/theme/chartPalette.ts new file mode 100644 index 00000000..5ee6f23a --- /dev/null +++ b/src/lib/theme/chartPalette.ts @@ -0,0 +1,7 @@ +export const appChartPalette = { + danger: 'var(--cw-danger-500)', + info: 'var(--cw-info-500)', + primary: 'var(--cw-primary-500)', + success: 'var(--cw-success-500)', + warning: 'var(--cw-warning-500)' +} as const; diff --git a/src/lib/types/pdfmake.d.ts b/src/lib/types/pdfmake.d.ts deleted file mode 100644 index b2bdc43e..00000000 --- a/src/lib/types/pdfmake.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -declare module 'pdfmake/build/pdfmake' { - interface TDocumentDefinitions { - content: any[]; - styles?: Record; - defaultStyle?: Record; - [key: string]: any; - } - - interface TFontDictionary { - [fontName: string]: { - normal?: string; - bold?: string; - italics?: string; - bolditalics?: string; - }; - } - - interface PdfDocument { - getBuffer(callback: (buffer: Uint8Array) => void): void; - download(filename: string): void; - } - - const pdfMake: { - vfs: Record; - fonts: TFontDictionary; - createPdf(documentDefinition: TDocumentDefinitions): PdfDocument; - }; - - export default pdfMake; -} - -declare module 'pdfmake/build/vfs_fonts' { - const pdfMake: { - vfs: Record; - }; - export default { pdfMake }; -} diff --git a/src/lib/utilities/ActiveTimer.ts b/src/lib/utilities/ActiveTimer.ts deleted file mode 100644 index 1fbd21ce..00000000 --- a/src/lib/utilities/ActiveTimer.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { browser } from '$app/environment'; - -// Type for the callback function -type SubscriberCallback = (value: boolean | null) => void; - -/** - * A store that checks if a date is older than the current time minus a specified interval. - * @param lastUpdated - The date to check - * @param intervalMinutes - The interval in minutes - * @returns A store with the value true if the date is within the interval, false if older, null if the interval is invalid - */ -export function createActiveTimer(lastUpdated: Date | null | undefined, intervalMinutes: number) { - // Initialize the state - let subscribers: SubscriberCallback[] = []; - let value = checkActive(lastUpdated, intervalMinutes); - let intervalId: number | undefined; - - // Function to check if a date is active - function checkActive(date: Date | null | undefined, interval: number): boolean | null { - // Handle invalid intervals by using a default value - if (interval <= 0) { - interval = 10; // Default to 10 minutes - } - - if (date === null || date === undefined) return false; - - // Use milliseconds for more precise calculation to avoid truncation issues - // Convert interval to milliseconds (minutes * 60 * 1000) - const now = new Date(); - const timeDiffMs = now.getTime() - new Date(date).getTime(); - const intervalMs = interval * 60 * 1000; - - // Device is active if time difference is less than the configured interval (no hard cap) - return timeDiffMs < intervalMs; - } - - // Function to notify all subscribers - function notify(): void { - subscribers.forEach((subscriber) => subscriber(value)); - } - - // Start the timer if we're in a browser environment - if (browser) { - // Calculate appropriate update interval based on the interval minutes - const updateFrequency = Math.min(30, Math.max(1, Math.floor(intervalMinutes / 10))) * 1000; - - intervalId = window.setInterval(() => { - const newValue = checkActive(lastUpdated, intervalMinutes); - if (newValue !== value) { - value = newValue; - notify(); - } - }, updateFrequency); - } - - // Return the store interface - return { - subscribe(callback: SubscriberCallback) { - subscribers.push(callback); - // Immediately call with current value - callback(value); - - // Return unsubscribe function - return () => { - subscribers = subscribers.filter((sub) => sub !== callback); - // If no more subscribers and we have an interval, clear it - if (subscribers.length === 0 && intervalId !== undefined && browser) { - clearInterval(intervalId); - } - }; - }, - update(newLastUpdated: Date | null | undefined, newIntervalMinutes: number): void { - // Update the parameters and recalculate - lastUpdated = newLastUpdated; - intervalMinutes = newIntervalMinutes; - - // Update the current value - const newValue = checkActive(lastUpdated, intervalMinutes); - if (newValue !== value) { - value = newValue; - notify(); - } - - // If we're in a browser environment, update the interval frequency - if (browser && intervalId !== undefined) { - clearInterval(intervalId); - - // Calculate appropriate update interval based on the new interval minutes - const updateFrequency = Math.min(30, Math.max(1, Math.floor(intervalMinutes / 10))) * 1000; - - intervalId = window.setInterval(() => { - const newValue = checkActive(lastUpdated, intervalMinutes); - if (newValue !== value) { - value = newValue; - notify(); - } - }, updateFrequency); - } - } - }; -} diff --git a/src/lib/utilities/ConvertSensorDataObject.ts b/src/lib/utilities/ConvertSensorDataObject.ts deleted file mode 100644 index f1bafc05..00000000 --- a/src/lib/utilities/ConvertSensorDataObject.ts +++ /dev/null @@ -1,87 +0,0 @@ -type SensorInput = Record & { - primaryData?: string; - secondaryData?: string; - primary_data_notation?: string; - secondary_data_notation?: string; -}; - -export function convertObject( - input: SensorInput | null | undefined, - keepHidden: boolean = false -): Record { - if (!input) { - return {}; - } - - const keysToKeep: string[] = [ - 'temperature', - 'temperatureC', - 'humidity', - 'dewPointC', - 'rainfall', - 'pressure', - 'wind_speed', - 'wind_direction', - 'lux', - 'uv', - 'co2', - 'CO2', - 'co2_level', - 'soil_N', - 'soil_P', - 'soil_K', - 'soil_PH', - 'ec', - 'soil_EC', - 'soil_moisture', - 'soil_temperatureC', - 'temperature_c', - 'created_at', - 'battery_level', - 'people_count', - 'car_count', - 'bicycle_count', - 'motorcycle_count', - 'soil_temperature', - 'soil_moisture', - 'moisture', - 'soil_humidity', - 'soil_ec', - 'soil_ph', - 'ph', - 'soil_nitrogen', - 'soil_phosphorus', - 'soil_potassium', - 'soil_salinity', - 'soil_n', - 'soil_p', - 'soil_k', - 'water_level', - 'water_temperature', - 'water_ph' - ]; - - if (keepHidden) { - keysToKeep.push('fireAlarm'); - } - - const output: Record = {}; - - for (const key of keysToKeep) { - if (Object.prototype.hasOwnProperty.call(input, key)) { - output[key] = input[key]; - } - } - - if (input.primaryData && input.primaryData in output && input.primary_data_notation) { - const targetKey = input.primaryData; - output[targetKey] = `${output[targetKey]} ${input.primary_data_notation}`.trim(); - } - - if (input.secondaryData && input.secondaryData in output && input.secondary_data_notation) { - const targetKey = input.secondaryData; - output[targetKey] = `${output[targetKey]} ${input.secondary_data_notation}`.trim(); - } - - return output; -} diff --git a/src/lib/utilities/NameToEmoji.ts b/src/lib/utilities/NameToEmoji.ts deleted file mode 100644 index 66432e20..00000000 --- a/src/lib/utilities/NameToEmoji.ts +++ /dev/null @@ -1,83 +0,0 @@ -export const nameToEmoji = (name: string) => { - switch (name) { - case 'soil_moisture': - case 'moisture': - case 'soil_humidity': - return '💧'; - case 'humidity': - return '💨'; - case 'dew_point': - case 'dew_pointC': - case 'dewPointC': - return '💦'; - case 'temperature': - case 'temperatureC': - case 'soil_temperatureC': - case 'temperature_c': - case 'soil_temperature': - return '🌡️'; - case 'soil_EC': - case 'soil_ec': - case 'ec': - return '🧂'; - case 'soil_N': - case 'soil_P': - case 'soil_K': - case 'soil_n': - case 'soil_p': - case 'soil_k': - return '🧪'; - case 'soil_PH': - case 'soil_ph': - case 'ph': - return '⚗️'; - case 'co2_level': - case 'co2': - return '⌬'; - case 'vpd': - return '💦'; - case 'rainfall': - return '🌧️'; - case 'pressure': - return '🕕'; - case 'created_at': - return '⌛'; - case 'wind_speed': - return '🍃'; - case 'lux': - return '☀️'; - case 'uv': - return '☢️'; - case 'wind_direction': - return '🧭'; - case 'water_level': - case 'depth_cm': - case 'deapth_cm': - return '📏'; - case 'battery_level': - case 'battery': - case 'Battery': - return '🔋'; - case 'sos': - return '🆘'; - case 'fire': - return '🔥'; - case 'people_count': - return '🧍'; - case 'car_count': - return '🚗'; - case 'bicycle_count': - return '🚲'; - case 'motorcycle_count': - return '🏍️'; - case 'truck_count': - return '🚚'; - case 'bus_count': - return '🚌'; - case 'relay_1': - case 'relay_2': - return '🔌'; - default: - return ''; - } -}; diff --git a/src/lib/utilities/NameToNotation.ts b/src/lib/utilities/NameToNotation.ts deleted file mode 100644 index b66e3b32..00000000 --- a/src/lib/utilities/NameToNotation.ts +++ /dev/null @@ -1,91 +0,0 @@ -export const nameToNotation = (name: string) => { - switch (name) { - case 'humidity': - case 'moisture': - case 'soil_moisture': - return '%'; - case 'Temperature': - case 'temperature': - case 'temperatureC': - case 'temperature_c': - case 'dew_point': - case 'dew_pointC': - case 'dewPointC': - return '°C'; - case 'soil_EC': - return 'mS/m'; - case 'soil_N': - return 'mg/kg'; - case 'soil_P': - return 'mg/kg'; - case 'soil_K': - return 'mg/kg'; - case 'soil_PH': - case 'ph': - return 'pH'; - case 'co2_level': - case 'co2': - return 'ppm'; - case 'vpd': - return 'hPk'; - case 'rainfall': - return 'mm/hr'; - case 'pressure': - return 'hPa'; - case 'wind_speed': - return 'm/s'; - case 'lux': - return 'lux'; - case 'uv': - case 'uv_index': - return 'UVI'; - case 'soil_temperature': - case 'soil_temperatureC': - return '°C'; - case 'soil_humidity': - return '%'; - case 'soil_ec': - case 'ec': - return 'mS/cm'; - case 'soil_ph': - return 'pH'; - case 'soil_nitrogen': - case 'soil_n': - return 'mg/kg'; - case 'soil_phosphorus': - case 'soil_p': - return 'mg/kg'; - case 'soil_potassium': - case 'soil_k': - return 'mg/kg'; - case 'wind_direction': - return '°'; - case 'water_level': - return 'm'; - case 'battery_level': - case 'battery': - case 'Battery': - return '%'; - case 'sos': - return 'SOS'; - case 'people_count': - return ''; - case 'car_count': - return ''; - case 'bicycle_count': - return ''; - case 'truck_count': - return ''; - case 'bus_count': - return ''; - case 'motorcycle_count': - return ''; - case 'depth_cm': - case 'deapth_cm': - return 'cm'; - case 'created_at': - return 'Last Updated'; - default: - return ''; - } -}; diff --git a/src/lib/utilities/dashboard.ts b/src/lib/utilities/dashboard.ts deleted file mode 100644 index 9e6e75f2..00000000 --- a/src/lib/utilities/dashboard.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Location } from '../models/Location'; - -/** - * Dashboard utility functions - * - * These functions are used across the dashboard components to handle - * common operations like checking device status, formatting data, etc. - */ - -type DeviceWithSensorData = { - dev_eui: string; - upload_interval?: number | null; - type?: number | null; - name?: string | null; - latestData?: Record | null; - deviceType?: { - default_upload_interval?: number | null; - name?: string | null; - isActive?: boolean; - }; -}; - -type LocationWithCount = { - cw_devices?: DeviceWithSensorData[] | null; -}; - -/** - * Checks if a device is currently active based on its last update time and upload interval - * @param device - The device to check - * @param deviceActiveStatus - Record of device active statuses by device ID - * @returns boolean indicating if the device is active - */ -export function isDeviceActive( - device: DeviceWithSensorData, - deviceActiveStatus: Record -): boolean { - if (!device) return false; - - // Get the device ID - const devEui = device.dev_eui as string; - - // Special handling for devices with negative upload intervals (always active) - const uploadInterval = device.upload_interval || device.deviceType?.default_upload_interval || 10; - if (uploadInterval <= 0) { - return true; - } - - // Special handling for soil sensors - if (isSoilSensor(device)) { - if (device.deviceType?.isActive !== undefined) { - return Boolean(device.deviceType.isActive); - } - - // If the soil sensor has moisture data, consider it active - if (device.latestData && 'moisture' in device.latestData) { - return true; - } - } - - return getDeviceActiveStatus(devEui, deviceActiveStatus); -} - -/** - * Helper function to get a device's active status from the status map - * @param deviceId - The device ID to check - * @param deviceActiveStatus - Record of device active statuses by device ID - * @returns boolean indicating if the device is active - */ -export function getDeviceActiveStatus( - deviceId: string, - deviceActiveStatus: Record -): boolean { - return Boolean(deviceActiveStatus[deviceId]); -} - -/** - * Determines if a device is a soil sensor based on its type, name, or data - * @param device - The device to check - * @returns boolean indicating if the device is a soil sensor - */ -export function isSoilSensor(device: DeviceWithSensorData): boolean { - // Check device name for soil-related terms - const deviceName = device.name?.toLowerCase() || ''; - const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; - - // Check device type (type 17 is soil sensor in your system) - if (device.type === 17) { - return true; - } - - // Check if the device name or type contains soil-related terms - return ( - deviceName.includes('soil') || - deviceName.includes('moisture') || - deviceTypeName.includes('soil') || - deviceTypeName.includes('moisture') || - Boolean(device.latestData && 'moisture' in device.latestData) - ); -} - -/** - * Gets active status indicators for a location - * @param location - The location to check - * @param deviceActiveStatus - Record of device active statuses by device ID - * @returns Object with active devices array and status flags - */ -export function getLocationActiveStatus( - location: LocationWithCount, - deviceActiveStatus: Record -): { activeDevices: DeviceWithSensorData[]; allActive: boolean; allInactive: boolean } { - const locationDevices = location?.cw_devices ?? []; - - if (locationDevices.length === 0) { - return { activeDevices: [], allActive: false, allInactive: false }; - } - - const activeDevices = locationDevices.filter((device) => - isDeviceActive(device, deviceActiveStatus) - ); - - const allActive = locationDevices.every((device) => isDeviceActive(device, deviceActiveStatus)); - - const allInactive = locationDevices.every( - (device) => !isDeviceActive(device, deviceActiveStatus) - ); - - return { activeDevices, allActive, allInactive }; -} - -/** - * Gets the CSS class for the container based on the view type - * @param viewType - The view type (grid, mozaic, list) - * @returns CSS class string - */ -export function getContainerClass(viewType: string): string { - switch (viewType) { - case 'grid': - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - case 'mozaic': - return 'columns-[20rem] gap-4 space-y-4'; - case 'list': - return 'flex flex-col gap-4'; - default: - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - } -} - -/** - * Handles keyboard navigation for location selection - * @param e - Keyboard event - * @param location - The location - * @param handleLocationClick - Function to handle location selection - */ -export function handleKeyDown( - e: KeyboardEvent, - location: Location, - handleLocationClick: (location: Location) => void -): void { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleLocationClick(location); - } -} diff --git a/src/lib/utilities/dashboardHelpers.ts b/src/lib/utilities/dashboardHelpers.ts deleted file mode 100644 index d602bbd6..00000000 --- a/src/lib/utilities/dashboardHelpers.ts +++ /dev/null @@ -1,73 +0,0 @@ -// $lib/utilities/dashboardHelpers.ts - -export function isSoilSensor(device: any): boolean { - const deviceName = device.name?.toLowerCase() || ''; - const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; - if (device.type === 17) return true; - return ( - deviceName.includes('soil') || - deviceName.includes('moisture') || - deviceTypeName.includes('soil') || - deviceTypeName.includes('moisture') || - (device.latestData && 'moisture' in device.latestData) - ); -} - -export function isDeviceActive(device: any, deviceActiveStatus: Record): boolean | null { - if (!device) return false; - const devEui = device.dev_eui as string; - - // Always check the deviceActiveStatus first - this comes from our timer logic - // This ensures we respect the 35-minute maximum threshold - if (devEui in deviceActiveStatus) { - // If the status is null, return null to indicate unknown/loading state - if (deviceActiveStatus[devEui] === null) { - return null; - } - return Boolean(deviceActiveStatus[devEui]); - } - - // Only use these fallbacks if we don't have timer data - const uploadInterval = device.upload_interval || device.deviceType?.default_upload_interval || 10; - - // Default to inactive for devices with invalid upload intervals - if (uploadInterval <= 0) { - return false; - } - - // Only consider device type isActive if we don't have timer data - if (device.deviceType?.isActive !== undefined) { - return Boolean(device.deviceType.isActive); - } - - // Default to inactive if we can't determine status - return false; -} - -export function getDeviceActiveStatus( - devEui: string | null, - deviceActiveStatus: Record -): boolean { - if (!devEui || deviceActiveStatus[devEui] == null) return false; - return Boolean(deviceActiveStatus[devEui]); -} - -export function getLocationActiveStatus( - location: any, - deviceActiveStatus: Record -) { - if (!location || !location.cw_devices || location.cw_devices.length === 0) { - return { activeDevices: [], allActive: false, allInactive: false }; - } - const locationDevices = location.cw_devices; - const activeDevices = locationDevices.filter((device: any) => - isDeviceActive(device, deviceActiveStatus) - ); - const allActive = - locationDevices.length > 0 && - locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus)); - const allInactive = - locationDevices.length > 0 && - locationDevices.every((device: any) => !isDeviceActive(device, deviceActiveStatus)); - return { activeDevices, allActive, allInactive }; -} diff --git a/src/lib/utilities/dashboardLayout.ts b/src/lib/utilities/dashboardLayout.ts deleted file mode 100644 index 32447371..00000000 --- a/src/lib/utilities/dashboardLayout.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Dashboard layout utility functions - */ - -/** - * Gets the CSS class for the container based on the view type - * @param viewType - The view type (grid, mozaic, list) - * @returns CSS class string - */ -export function getContainerClass(viewType: string): string { - switch (viewType) { - case 'grid': - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - case 'mozaic': - return 'columns-[20rem] gap-4 space-y-4'; - case 'list': - return 'flex flex-col gap-4'; - default: - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - } -} - -/** - * Handles keyboard navigation for location selection - * @param e - Keyboard event - * @param location - The location - * @param selectCallback - Function to handle location selection - */ -export function handleKeyDown( - e: KeyboardEvent, - location: any, - selectCallback: (location: any) => void -): void { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - selectCallback(location); - } -} diff --git a/src/lib/utilities/deviceOrderStorage.ts b/src/lib/utilities/deviceOrderStorage.ts deleted file mode 100644 index 916cabde..00000000 --- a/src/lib/utilities/deviceOrderStorage.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Device Order Management - handles saving/loading device order from localStorage - */ - -export interface DeviceOrder { - locationId: number; - deviceOrder: string[]; // Array of dev_eui strings in order -} - -const STORAGE_KEY = 'cropwatch_device_order'; - -/** - * Save device order for a specific location to localStorage - */ -export function saveDeviceOrder(locationId: number, deviceOrder: string[]): void { - try { - if (typeof localStorage === 'undefined') return; - - const existingData = getDeviceOrderData(); - const locationIndex = existingData.findIndex((item) => item.locationId === locationId); - - const newOrderData: DeviceOrder = { locationId, deviceOrder }; - - if (locationIndex >= 0) { - // Update existing location order - existingData[locationIndex] = newOrderData; - } else { - // Add new location order - existingData.push(newOrderData); - } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(existingData)); - } catch (error) { - console.warn('Failed to save device order to localStorage:', error); - } -} - -/** - * Get device order for a specific location from localStorage - */ -export function getDeviceOrder(locationId: number): string[] | null { - try { - if (typeof localStorage === 'undefined') return null; - - const data = getDeviceOrderData(); - const locationData = data.find((item) => item.locationId === locationId); - - return locationData ? locationData.deviceOrder : null; - } catch (error) { - console.warn('Failed to load device order from localStorage:', error); - return null; - } -} - -/** - * Get all device order data from localStorage - */ -function getDeviceOrderData(): DeviceOrder[] { - try { - if (typeof localStorage === 'undefined') return []; - - const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch (error) { - console.warn('Failed to parse device order data from localStorage:', error); - return []; - } -} - -/** - * Apply stored device order to an array of devices - */ -export function applyStoredDeviceOrder( - devices: T[], - locationId: number -): T[] { - const storedOrder = getDeviceOrder(locationId); - if (!storedOrder) return devices; - - // Create a map for quick lookup - const deviceMap = new Map(); - devices.forEach((device) => deviceMap.set(device.dev_eui, device)); - - // Build ordered array based on stored order - const orderedDevices: T[] = []; - const usedDevices = new Set(); - - // First, add devices in stored order - storedOrder.forEach((devEui) => { - const device = deviceMap.get(devEui); - if (device) { - orderedDevices.push(device); - usedDevices.add(devEui); - } - }); - - // Then, add any new devices that weren't in the stored order - devices.forEach((device) => { - if (!usedDevices.has(device.dev_eui)) { - orderedDevices.push(device); - } - }); - - return orderedDevices; -} - -/** - * Clear all stored device orders - */ -export function clearDeviceOrders(): void { - try { - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } - } catch (error) { - console.warn('Failed to clear device orders from localStorage:', error); - } -} diff --git a/src/lib/utilities/deviceTimerManager.ts b/src/lib/utilities/deviceTimerManager.ts deleted file mode 100644 index 49f6fc5e..00000000 --- a/src/lib/utilities/deviceTimerManager.ts +++ /dev/null @@ -1,223 +0,0 @@ -// $lib/utilities/deviceTimerManager.ts -import { createActiveTimer } from './ActiveTimer'; -import { getDeviceLatestTimestamp } from './deviceUtils'; -import type { DeviceWithType } from '$lib/models/Device'; -import type { AirData } from '$lib/models/AirData'; -import type { SoilData } from '$lib/models/SoilData'; - -// Define DeviceWithSensorData type -interface DeviceWithSensorData extends DeviceWithType { - latestData: AirData | SoilData | null; - cw_rules?: any[]; -} - -// Type for the callback function that will be called when a device's active status changes -type DeviceStatusCallback = (deviceId: string, isActive: boolean | null) => void; - -// Type for the refresh callback function that will be called during polling -type RefreshCallback = (locationId: number) => Promise; - -/** - * DeviceTimerManager class to manage device active timers and polling intervals - * This class encapsulates all the logic for setting up, cleaning up, and managing device active timers and polling - */ -export class DeviceTimerManager { - // Map to track device timers by device ID - private deviceTimers: Record = {}; - - // Array to store unsubscribe functions for active timers - private unsubscribers: (() => void)[] = []; - - // Map to track device active status by device ID - private deviceActiveStatus: Record = {}; - - // Polling interval ID - private pollingIntervalId: number | null = null; - - // Map to track last refresh timestamp by location ID - private lastRefreshTimestamp: Record = {}; - - /** - * Set up a device active timer for a specific device - * @param device The device to set up the timer for - * @param uploadInterval The upload interval in minutes - * @param onStatusChange Optional callback to call when the device active status changes - */ - setupDeviceActiveTimer( - device: DeviceWithSensorData, - uploadInterval: number, - onStatusChange?: (deviceId: string, isActive: boolean | null) => void - ): void { - const latestTimestamp = getDeviceLatestTimestamp(device); - if (!latestTimestamp) return; - const deviceId = device.dev_eui as string; - - // Clear any existing timer for this device - this.cleanupDeviceTimer(deviceId); - - // Use provided uploadInterval or fallback to device settings - const effectiveInterval = - uploadInterval || - device.upload_interval || - device.cw_device_type?.default_upload_interval || - 10; - - // Create a closure to capture the current timestamp - const currentTimestamp = latestTimestamp; - - const activeTimer = createActiveTimer(new Date(currentTimestamp), Number(effectiveInterval)); - - // Update device active status when timer changes - const unsubscribe = activeTimer.subscribe((isActive) => { - // Only update if this is still the current device data - // This prevents old timers from incorrectly marking devices as inactive - if (getDeviceLatestTimestamp(device) === currentTimestamp) { - //console.log(`Device ${device.name} (${deviceId}) status updated:`, { - // isActive, - // lastUpdate: currentTimestamp, - // uploadInterval - // }); - this.deviceActiveStatus[deviceId] = isActive; - - // Call the callback if provided - if (onStatusChange) { - onStatusChange(deviceId, isActive); - } - } else { - //console.log(`Ignoring outdated timer update for ${device.name} (${deviceId})`, { - // timerTimestamp: currentTimestamp, - // currentTimestamp: device.latestData?.created_at - // }); - } - }); - - // Store the unsubscribe function for cleanup and track its index - const timerIndex = this.unsubscribers.length; - this.unsubscribers.push(unsubscribe); - this.deviceTimers[deviceId] = timerIndex; - } - - /** - * Clean up a device timer - * @param deviceId The ID of the device to clean up the timer for - */ - cleanupDeviceTimer(deviceId: string) { - // Find the index of the timer for this device - const timerIndex = this.deviceTimers[deviceId]; - - // If we have a timer index for this device - if (timerIndex !== undefined && timerIndex >= 0 && timerIndex < this.unsubscribers.length) { - // Call the unsubscribe function - this.unsubscribers[timerIndex](); - - // Remove the unsubscribe function from the array (replace with a no-op) - this.unsubscribers[timerIndex] = () => {}; - - // Remove the device from the timers map - delete this.deviceTimers[deviceId]; - } - } - - /** - * Clean up all device timers - */ - cleanupTimers() { - // Call all unsubscribe functions - this.unsubscribers.forEach((unsub) => unsub()); - - // Clear the arrays - this.unsubscribers = []; - this.deviceTimers = {}; - } - - /** - * Set up polling for a location - * @param locationId The ID of the location to poll for - * @param refreshCallback The callback function to call when polling - * @param intervalMs Optional polling interval in milliseconds (default: 120000 - 2 minutes) - */ - setupPolling(locationId: number, refreshCallback: RefreshCallback, intervalMs: number = 120000) { - //console.log('setupPolling called'); - // Clean up any existing polling interval - this.cleanupPolling(); - - // Set up new polling interval - const intervalId = setInterval(async () => { - //console.log(`[Polling] Device data polling triggered at ${new Date().toLocaleString()}`); - if (locationId) { - await refreshCallback(locationId); - } - }, intervalMs); - - // Store the interval ID for cleanup - this.pollingIntervalId = intervalId as unknown as number; - } - - /** - * Clean up polling interval - */ - cleanupPolling() { - if (this.pollingIntervalId !== null) { - clearInterval(this.pollingIntervalId); - this.pollingIntervalId = null; - } - } - - /** - * Update the last refresh timestamp for a location - * @param locationId The ID of the location - * @param timestamp The timestamp to set (default: current time) - */ - updateRefreshTimestamp(locationId: number, timestamp: number = Date.now()) { - this.lastRefreshTimestamp[locationId] = timestamp; - } - - /** - * Get the last refresh timestamp for a location - * @param locationId The ID of the location - * @returns The last refresh timestamp, or 0 if the location has never been refreshed - */ - getLastRefreshTimestamp(locationId: number): number { - return this.lastRefreshTimestamp[locationId] || 0; - } - - /** - * Check if a location needs to be refreshed based on a minimum interval - * @param locationId The ID of the location - * @param minIntervalMs The minimum interval in milliseconds (default: 30000 - 30 seconds) - * @returns True if the location needs to be refreshed, false otherwise - */ - needsRefresh(locationId: number, minIntervalMs: number = 30000): boolean { - const now = Date.now(); - const lastRefresh = this.getLastRefreshTimestamp(locationId); - const timeSinceLastRefresh = now - lastRefresh; - - return timeSinceLastRefresh >= minIntervalMs; - } - - /** - * Get the active status for a specific device - * @param deviceId The device ID to get the active status for - * @returns The active status of the device, or false if not found - */ - getDeviceActiveStatus(deviceId: string): boolean { - // Convert null to false, but keep true/false values as is - return this.deviceActiveStatus[deviceId] ?? false; - } - - /** - * Get a record of all device active statuses - * @returns A record of device active statuses by device ID - */ - getDeviceActiveStatusRecord(): Record { - return { ...this.deviceActiveStatus }; - } -} - -/** - * Creates a new DeviceTimerManager instance - * @returns A new DeviceTimerManager instance - */ -export function createDeviceTimerManager(): DeviceTimerManager { - return new DeviceTimerManager(); -} diff --git a/src/lib/utilities/deviceTimerSetup.ts b/src/lib/utilities/deviceTimerSetup.ts deleted file mode 100644 index c377926d..00000000 --- a/src/lib/utilities/deviceTimerSetup.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { DeviceTimerManager } from './deviceTimerManager'; -import { getDeviceLatestTimestamp } from './deviceUtils'; -import type { DeviceWithType } from '$lib/models/Device'; -import type { AirData } from '$lib/models/AirData'; -import type { SoilData } from '$lib/models/SoilData'; - -// Define the DeviceWithSensorData type -export interface DeviceWithSensorData extends DeviceWithType { - latestData: AirData | SoilData | null; - cw_rules?: any[]; -} - -/** - * Sets up an active timer for a device to track its online/offline status - * @param device The device to set up a timer for - * @param timerManager The timer manager instance - * @param deviceActiveStatus The record of device active status - */ -export function setupDeviceActiveTimer( - device: DeviceWithSensorData, - timerManager: DeviceTimerManager, - deviceActiveStatus: Record -) { - const latestTimestamp = getDeviceLatestTimestamp(device); - if (!latestTimestamp) return; - const deviceId = device.dev_eui as string; - - // Get the upload interval from the device - const uploadInterval = - device.upload_interval || device.cw_device_type?.default_upload_interval || 10; - - // Use the timer manager to set up a timer for this device - timerManager.setupDeviceActiveTimer( - device, - uploadInterval, - (deviceId: string, isActive: boolean | null) => { - // Update the device active status in our component state - deviceActiveStatus[deviceId] = isActive === null ? false : isActive; - } - ); -} diff --git a/src/lib/utilities/deviceUtils.ts b/src/lib/utilities/deviceUtils.ts deleted file mode 100644 index daa5d7cc..00000000 --- a/src/lib/utilities/deviceUtils.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Device utility functions - */ - -/** - * Determines if a device is a soil sensor based on its type, name, or data - * @param device - The device to check - * @returns boolean indicating if the device is a soil sensor - */ -export function isSoilSensor(device: any): boolean { - // Check device name for soil-related terms - const deviceName = device.name?.toLowerCase() || ''; - const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; - - // Check device type (type 17 is soil sensor in your system) - if (device.type === 17) { - return true; - } - - // Check if the device name or type contains soil-related terms - return ( - deviceName.includes('soil') || - deviceName.includes('moisture') || - deviceTypeName.includes('soil') || - deviceTypeName.includes('moisture') || - // Check if the device has soil-specific data points - (device.latestData && 'moisture' in device.latestData) - ); -} - -/** - * Returns the most relevant timestamp for a device based on its latest data - * Prefers latestData.last_update, then device.last_update, then created_at fallbacks - */ -export function getDeviceLatestTimestamp(device: { - latestData?: Record | null; - created_at?: string | Date | null; - last_update?: string | Date | null; -}): string | null { - if (!device) return null; - const latest = device.latestData ?? null; - const candidates: Array = [ - latest?.last_update, - device.last_update, - latest?.created_at, - device.created_at - ]; - - for (const candidate of candidates) { - if (!candidate) continue; - if (candidate instanceof Date) { - return candidate.toISOString(); - } - return candidate; - } - - return null; -} - -/** - * Checks if a device is currently active based on its last update time and upload interval - * @param device - The device to check - * @param deviceActiveStatus - Record of device active statuses by device ID - * @returns boolean indicating if the device is active - */ -export function isDeviceActive(device: any, deviceActiveStatus: Record): boolean { - if (!device) return false; - - // Get the device ID - const devEui = device.dev_eui as string; - - // Special handling for devices with negative upload intervals (always active) - const uploadInterval = device.upload_interval || device.deviceType?.default_upload_interval || 10; - if (uploadInterval <= 0) { - return true; - } - - return Boolean(deviceActiveStatus[devEui]); -} - -/** - * Gets active status indicators for a location - * @param location - The location to check - * @param deviceActiveStatus - Record of device active statuses by device ID - * @returns Object with active devices array and status flags - */ -export function getLocationActiveStatus( - location: any, - deviceActiveStatus: Record -) { - if (!location || !location.cw_devices || location.cw_devices.length === 0) { - return { activeDevices: [], allActive: false, allInactive: false }; - } - - const locationDevices = location.cw_devices; - // Use isDeviceActive instead of getDeviceActiveStatus for consistency - const activeDevices = locationDevices.filter((device: any) => - isDeviceActive(device, deviceActiveStatus) - ); - - const allActive = - locationDevices.length > 0 && - locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus)); - - const allInactive = - locationDevices.length > 0 && - locationDevices.every((device: any) => !isDeviceActive(device, deviceActiveStatus)); - - return { activeDevices, allActive, allInactive }; -} diff --git a/src/lib/utilities/dragAndDrop.ts b/src/lib/utilities/dragAndDrop.ts deleted file mode 100644 index b9dfb7d2..00000000 --- a/src/lib/utilities/dragAndDrop.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Drag and Drop utilities for reordering lists - */ - -export interface DragState { - draggedIndex: number | null; - draggedItem: any | null; - dropTargetIndex: number | null; -} - -export function createDragState(): DragState { - return { - draggedIndex: null, - draggedItem: null, - dropTargetIndex: null - }; -} - -export function reorderArray(array: T[], fromIndex: number, toIndex: number): T[] { - if (fromIndex === toIndex) return array; - - const newArray = [...array]; - const item = newArray[fromIndex]; - - // Remove the item from its current position - newArray.splice(fromIndex, 1); - - // Insert the item at the new position - newArray.splice(toIndex, 0, item); - - return newArray; -} - -export function createDragHandlers( - items: T[], - onReorder: (newItems: T[]) => void, - dragState: DragState, - updateDragState: (newState: Partial) => void -) { - return { - handleDragStart: (event: DragEvent, index: number) => { - console.log('Drag start:', index, items[index]); - updateDragState({ - draggedIndex: index, - draggedItem: items[index], - dropTargetIndex: null - }); - }, - - handleDragEnd: (event: DragEvent) => { - const { draggedIndex, dropTargetIndex } = dragState; - console.log('Drag end:', { draggedIndex, dropTargetIndex }); - - if (draggedIndex !== null && dropTargetIndex !== null && draggedIndex !== dropTargetIndex) { - console.log('Reordering from', draggedIndex, 'to', dropTargetIndex); - const reorderedItems = reorderArray(items, draggedIndex, dropTargetIndex); - onReorder(reorderedItems); - } - - updateDragState({ - draggedIndex: null, - draggedItem: null, - dropTargetIndex: null - }); - }, - - handleDragOver: (event: DragEvent, index: number) => { - event.preventDefault(); - console.log('Drag over:', index); - updateDragState({ - dropTargetIndex: index - }); - }, - - handleDrop: (event: DragEvent, index: number) => { - event.preventDefault(); - console.log('Drop on:', index); - updateDragState({ - dropTargetIndex: index - }); - } - }; -} diff --git a/src/lib/utilities/duration.ts b/src/lib/utilities/duration.ts deleted file mode 100644 index 16cbc115..00000000 --- a/src/lib/utilities/duration.ts +++ /dev/null @@ -1,37 +0,0 @@ -export enum DurationUnits { - Millisecond = 0, - Second = 1, - Minute = 2, - Hour = 3, - Day = 4 -} - -export function formatDuration( - start: string | Date, - { - totalUnits = 2, - minUnits = DurationUnits.Second, - now = Date.now() - }: { totalUnits?: number; minUnits?: DurationUnits; now?: number } = {} -) { - const startTime = new Date(start).getTime(); - const diffMs = now - startTime; - const units: { unit: DurationUnits; label: string; ms: number }[] = [ - { unit: DurationUnits.Day, label: 'd', ms: 86400000 }, - { unit: DurationUnits.Hour, label: 'h', ms: 3600000 }, - { unit: DurationUnits.Minute, label: 'm', ms: 60000 }, - { unit: DurationUnits.Second, label: 's', ms: 1000 } - ]; - const parts: string[] = []; - let remaining = diffMs; - for (const u of units) { - if (u.unit < minUnits) continue; - if (parts.length >= totalUnits) break; - const value = Math.floor(remaining / u.ms); - if (value > 0 || u.unit === minUnits) { - parts.push(`${value}${u.label}`); - remaining -= value * u.ms; - } - } - return parts.slice(0, totalUnits).join(' '); -} diff --git a/src/lib/utilities/helpers.ts b/src/lib/utilities/helpers.ts deleted file mode 100644 index 9c975dad..00000000 --- a/src/lib/utilities/helpers.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Helper function to format date for input fields -export function formatDateForInput(date: Date): string { - return date.toISOString().split('T')[0]; -} - -// Helper function to format date for display -export function formatDateForDisplay(dateStr: string): string { - const date = new Date(dateStr); - - // Format: 17 May 2025, 11:10 - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(',', '').replace(/\s+/g, ' '); -} - -// Helper function to format date only (no time) in a consistent format -export function formatDateOnly(dateStr: string): string { - const date = new Date(dateStr); - - // Format as "17 May 2025" - return date.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric' - }).replace(',', ''); -} - -// Helper function to calculate average -export function calculateAverage(values: number[]): number { - if (values.length === 0) return 0; - const sum = values.reduce((a, b) => a + b, 0); - return parseFloat((sum / values.length).toFixed(2)); -} - -// Helper function to determine if a property exists and has a value -export function hasValue(obj: any, prop: string): boolean { - return obj && prop in obj && obj[prop] !== null; -} \ No newline at end of file diff --git a/src/lib/utilities/logger.ts b/src/lib/utilities/logger.ts deleted file mode 100644 index c39a0ccd..00000000 --- a/src/lib/utilities/logger.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function info(message: string, ...optional: unknown[]): void { - console.info('[INFO]', message, ...optional); -} - -export function debug(message: string, ...optional: unknown[]): void { - console.debug('[DEBUG]', message, ...optional); -} diff --git a/src/lib/utilities/monthWeatherHistory.ts b/src/lib/utilities/monthWeatherHistory.ts deleted file mode 100644 index 7b2d91b4..00000000 --- a/src/lib/utilities/monthWeatherHistory.ts +++ /dev/null @@ -1,309 +0,0 @@ -// WMO Weather interpretation codes for icons -const getWeatherIcon = (weatherCode: number | null) => { - if (weatherCode === null) return '❓'; - const iconMap: { [key: number]: string } = { - 0: '☀️', // Clear sky - 1: '🌤️', // Mainly clear - 2: '⛅', // Partly cloudy - 3: '☁️', // Overcast - 45: '🌫️', // Fog - 48: '🌫️', // Depositing rime fog - 51: '🌦️', // Light drizzle - 53: '🌦️', // Moderate drizzle - 55: '🌦️', // Dense drizzle - 56: '🌧️', // Light freezing drizzle - 57: '🌧️', // Dense freezing drizzle - 61: '🌧️', // Slight rain - 63: '🌧️', // Moderate rain - 65: '🌧️', // Heavy rain - 66: '🌧️', // Light freezing rain - 67: '🌧️', // Heavy freezing rain - 71: '🌨️', // Slight snow fall - 73: '🌨️', // Moderate snow fall - 75: '🌨️', // Heavy snow fall - 77: '🌨️', // Snow grains - 80: '🌦️', // Slight rain showers - 81: '🌦️', // Moderate rain showers - 82: '🌦️', // Violent rain showers - 85: '🌨️', // Slight snow showers - 86: '🌨️', // Heavy snow showers - 95: '⛈️', // Thunderstorm - 96: '⛈️', // Thunderstorm with slight hail - 99: '⛈️' // Thunderstorm with heavy hail - }; - return iconMap[weatherCode] || '❓'; -}; - -const getWeatherDescription = (weatherCode: number | null) => { - if (weatherCode === null) return 'Unknown'; - const descriptionMap: { [key: number]: string } = { - 0: 'Clear sky', - 1: 'Mainly clear', - 2: 'Partly cloudy', - 3: 'Overcast', - 45: 'Fog', - 48: 'Depositing rime fog', - 51: 'Light drizzle', - 53: 'Moderate drizzle', - 55: 'Dense drizzle', - 56: 'Light freezing drizzle', - 57: 'Dense freezing drizzle', - 61: 'Slight rain', - 63: 'Moderate rain', - 65: 'Heavy rain', - 66: 'Light freezing rain', - 67: 'Heavy freezing rain', - 71: 'Slight snow fall', - 73: 'Moderate snow fall', - 75: 'Heavy snow fall', - 77: 'Snow grains', - 80: 'Slight rain showers', - 81: 'Moderate rain showers', - 82: 'Violent rain showers', - 85: 'Slight snow showers', - 86: 'Heavy snow showers', - 95: 'Thunderstorm', - 96: 'Thunderstorm with slight hail', - 99: 'Thunderstorm with heavy hail' - }; - return descriptionMap[weatherCode] || 'Unknown'; -}; - -// Cache management -interface WeatherCacheEntry { - date: string; - weather: { - maxtemp_c: number | null; - mintemp_c: number | null; - totalprecip_mm: number; - condition: { - text: string; - icon: string; - code: number | null; - }; - }; - cachedAt: number; // timestamp -} - -interface WeatherCache { - [key: string]: WeatherCacheEntry; // key is the date string -} - -const CACHE_KEY = 'cropwatch_weather_cache'; -const CACHE_EXPIRY_HOURS = 800; // Cache expires after 800 hours (slightly over a month) -const getCurrentYear = () => new Date().getFullYear(); - -// Get cached weather data -function getWeatherCache(): WeatherCache { - if (typeof window === 'undefined') return {}; - - try { - const cached = localStorage.getItem(CACHE_KEY); - if (!cached) return {}; - - const cache: WeatherCache = JSON.parse(cached); - const now = Date.now(); - const currentYear = getCurrentYear(); - - // Filter out expired entries and entries from other years - const validCache: WeatherCache = {}; - Object.entries(cache).forEach(([date, entry]) => { - const entryYear = new Date(date).getFullYear(); - const isCurrentYear = entryYear === currentYear; - const isNotExpired = now - entry.cachedAt < CACHE_EXPIRY_HOURS * 60 * 60 * 1000; - - if (isCurrentYear && isNotExpired) { - validCache[date] = entry; - } - }); - - return validCache; - } catch (error) { - console.warn('Failed to read weather cache:', error); - return {}; - } -} - -// Save weather data to cache -function setWeatherCache(weatherData: WeatherCacheEntry[]): void { - if (typeof window === 'undefined') return; - - try { - const existingCache = getWeatherCache(); - const now = Date.now(); - - // Add new entries to cache - weatherData.forEach((entry) => { - existingCache[entry.date] = { - ...entry, - cachedAt: now - }; - }); - - localStorage.setItem(CACHE_KEY, JSON.stringify(existingCache)); - //console.log(`Cached ${weatherData.length} weather entries`); - } catch (error) { - console.warn('Failed to save weather cache:', error); - } -} - -// Get missing dates that need to be fetched -function getMissingDates(startDate: Date, endDate: Date): { start: Date; end: Date } | null { - const cache = getWeatherCache(); - const current = new Date(startDate); - const end = new Date(endDate); - - const missingDates: string[] = []; - - while (current <= end) { - const dateStr = current.toISOString().split('T')[0]; - if (!cache[dateStr]) { - missingDates.push(dateStr); - } - current.setDate(current.getDate() + 1); - } - - if (missingDates.length === 0) { - return null; // All dates are cached - } - - // Find continuous ranges to minimize API calls - const firstMissing = new Date(missingDates[0]); - const lastMissing = new Date(missingDates[missingDates.length - 1]); - - return { start: firstMissing, end: lastMissing }; -} - -export async function fetchHistoricalWeather( - startDate: Date, - endDate: Date, - latitude: number = 34.6937, - longitude: number = 135.5023 -): Promise { - //console.log( - // `Fetching weather data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}` - //); - - // Check cache first - const cache = getWeatherCache(); - const cachedResults: WeatherCacheEntry[] = []; - - // Collect cached data for the requested range - const current = new Date(startDate); - const end = new Date(endDate); - - while (current <= end) { - const dateStr = current.toISOString().split('T')[0]; - if (cache[dateStr]) { - cachedResults.push(cache[dateStr]); - } - current.setDate(current.getDate() + 1); - } - - // Check if we need to fetch missing data - const missingRange = getMissingDates(startDate, endDate); - - if (!missingRange) { - //console.log('All data available in cache'); - return cachedResults.sort((a, b) => a.date.localeCompare(b.date)); - } - - // Fetch missing data from API - // test - const startDateStr = missingRange.start.toISOString().split('T')[0]; - const endDateStr = missingRange.end.toISOString().split('T')[0]; - - //console.log(`Fetching missing data from ${startDateStr} to ${endDateStr}`); - - // Open-Meteo Historical Weather API - const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${latitude}&longitude=${longitude}&start_date=${startDateStr}&end_date=${endDateStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code&timezone=auto`; - - try { - //console.log('Fetching weather from Open-Meteo:', url); - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const data = await response.json(); - //console.log('Open-Meteo response:', data); - - let newResults: WeatherCacheEntry[] = []; - - // Check if the response has the expected structure - if (data.daily && data.daily.time && data.daily.time.length > 0) { - newResults = data.daily.time.map((date: string, index: number) => ({ - date, - weather: { - maxtemp_c: data.daily.temperature_2m_max[index] ?? null, - mintemp_c: data.daily.temperature_2m_min[index] ?? null, - totalprecip_mm: data.daily.precipitation_sum[index] ?? 0, - condition: { - text: getWeatherDescription(data.daily.weather_code[index] ?? null), - icon: getWeatherIcon(data.daily.weather_code[index] ?? null), - code: data.daily.weather_code[index] ?? null - } - }, - cachedAt: Date.now() - })); - - // Cache the new results - setWeatherCache(newResults); - //console.log(`Fetched and cached ${newResults.length} new entries`); - } else { - console.warn('No weather data available in response:', data); - } - - // Combine cached and new results - const allResults = [...cachedResults, ...newResults]; - - // Filter to only return data within the requested range and sort by date - const filteredResults = allResults - .filter((entry) => { - const entryDate = new Date(entry.date); - return entryDate >= startDate && entryDate <= endDate; - }) - .sort((a, b) => a.date.localeCompare(b.date)); - - //console.log(`Returning ${filteredResults.length} total weather entries`); - return filteredResults; - } catch (error) { - console.error('Failed to fetch weather data:', error); - // Return cached data even if API fails - if (cachedResults.length > 0) { - //console.log('API failed, returning cached data only'); - return cachedResults.sort((a, b) => a.date.localeCompare(b.date)); - } - return []; - } -} - -// Utility function to clear old cache entries (optional) -export function clearWeatherCache(): void { - if (typeof window !== 'undefined') { - localStorage.removeItem(CACHE_KEY); - //console.log('Weather cache cleared'); - } -} - -// Utility function to get cache statistics (optional) -export function getWeatherCacheStats(): { - entries: number; - oldestEntry: string | null; - newestEntry: string | null; -} { - const cache = getWeatherCache(); - const entries = Object.keys(cache).length; - - if (entries === 0) { - return { entries: 0, oldestEntry: null, newestEntry: null }; - } - - const dates = Object.keys(cache).sort(); - return { - entries, - oldestEntry: dates[0], - newestEntry: dates[dates.length - 1] - }; -} diff --git a/src/lib/utilities/stats.ts b/src/lib/utilities/stats.ts deleted file mode 100644 index 3e81f039..00000000 --- a/src/lib/utilities/stats.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { getDarkMode } from '$lib/components/theme/theme.svelte'; -import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; - -/** - * Color selection for various statistics keys. - */ -const colors: Record = { - humidity: 'blue', - Humidity: 'blue', - moisture: 'blue', - Moisture: 'blue', - soil_moisture: 'blue', - Temperature: 'orange', - temperature: 'orange', - temperatureC: 'orange', - temperature_c: 'orange', - dew_point: 'orange', - dew_pointC: 'orange', - dewPointC: 'orange', - soil_EC: 'green', - soil_ec: 'green', - ec: 'green', - soil_N: 'red', - soil_n: 'red', - soil_nitrogen: 'red', - soil_P: 'red', - soil_p: 'red', - soil_phosphorus: 'red', - soil_K: 'red', - soil_k: 'red', - soil_potassium: 'red', - soil_PH: 'purple', - ph: 'purple', - soil_ph: 'yellow', - co2_level: 'purple', - co2: 'purple', - vpd: 'red', - rainfall: 'blue', - pressure: 'green', - wind_speed: 'yellow', - lux: 'red', - uv: 'red', - uv_index: 'red', - soil_temperature: 'orange', - soil_temperatureC: 'orange', - soil_humidity: 'sky', - wind_direction: 'red', - water_level: 'blue', - deapth_cm: 'blue', - battery_level: 'red', - battery: 'red', - Battery: 'red', - sos: 'red', - people_count: 'yellow', - car_count: 'red', - bicycle_count: 'green', - truck_count: 'silver', - bus_count: 'blue', - motorcycle_count: 'orange' -}; - -/** - * Get a color name associated with a given key. If the key is not found, it defaults to 'zinc'. - */ -export const getColorNameByKey = (key: string): string => colors[key] ?? 'red'; - -/** - * Get the background color based on the key. If isMetadata is true, it returns a transparent color. - * Otherwise, it returns a color based on the key. - */ -export const getBorderColorByKey = (key: string, isMetadata: boolean = false): string => { - const color = getColorNameByKey(key); - - if (isMetadata) { - return 'transparent'; - } - - return `var(--color-${color}-400)`; -}; - -/** - * Get the text color based on the key. If isMetadata is true, it returns a gray color. Otherwise, - * it returns a color based on the key. - */ -export const getTextColorByKey = (key: string, isMetadata: boolean = false): string => { - const color = getColorNameByKey(key); - - if (isMetadata) { - return 'var(--text-gray-400)'; - } - - return `var(--color-${color}-${getDarkMode() ? '400' : '500'})`; -}; - -/** - * Default options for number formatting. This sets the maximum number of fraction digits to 2. - */ -const defaultNumberFormatOptions: Intl.NumberFormatOptions = { maximumFractionDigits: 2 }; - -/** - * Custom format options for various statistics keys. - */ -const customNumberFormatOptions: Record = { - battery_level: { maximumFractionDigits: 0 }, - pressure: { maximumFractionDigits: 0 }, - lux: { maximumFractionDigits: 0 }, - people_count: { maximumFractionDigits: 0 }, - car_count: { maximumFractionDigits: 0 }, - bicycle_count: { maximumFractionDigits: 0 }, - truck_count: { maximumFractionDigits: 0 }, - bus_count: { maximumFractionDigits: 0 }, - motorcycle_count: { maximumFractionDigits: 0 } -}; - -/** - * Formats a number based on the provided key and locale. If the value is undefined, it returns - * 'N/A'. If the key is not found in formatOptions, it defaults to a maximum of 1 decimal place. - */ -export const formatNumber = ({ - locale = 'en', - key, - value, - adjustFractionDigits = false -}: { - locale?: string; - key: string; - value: number | string | undefined; - adjustFractionDigits?: boolean; -}): string => { - if (value === undefined) { - return 'N/A'; - } - - const options = customNumberFormatOptions[key] ?? defaultNumberFormatOptions; - - if (adjustFractionDigits) { - options.minimumFractionDigits = options.maximumFractionDigits; - } - - return Intl.NumberFormat(locale, options).format(Number(value)); -}; - -/** - * Gets all numeric keys from the historical data, excluding specified ignored keys. - * @param historicalData Historical data array. - * @param ignoredDataKeys Array of keys to ignore. - * @returns Array of numeric keys found in the data. - */ -export function getNumericKeys( - historicalData: DeviceDataRecord[], - ignoredDataKeys: string[] = ['id', 'dev_eui', 'created_at'] -): string[] { - if (!historicalData || !historicalData.length) { - return []; - } - - const sample = historicalData.find((row) => row && typeof row === 'object'); - - if (!sample) { - return []; - } - - return Object.keys(sample).filter( - (key) => !ignoredDataKeys.includes(key) && typeof sample[key] === 'number' - ); -} diff --git a/src/lib/utilities/windDirectionToCompass.ts b/src/lib/utilities/windDirectionToCompass.ts deleted file mode 100644 index ff2a7f80..00000000 --- a/src/lib/utilities/windDirectionToCompass.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function windDirectionToCompass( - degrees: number -): 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW' { - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const; - const index = Math.round((degrees % 360) / 45) % 8; - return directions[index]; -} diff --git a/src/lib/utils/auth-redirect.spec.ts b/src/lib/utils/auth-redirect.spec.ts new file mode 100644 index 00000000..dcd6c206 --- /dev/null +++ b/src/lib/utils/auth-redirect.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLoginPath, + readLoginReason, + sanitizeRedirectPath, + withRedirectParam +} from './auth-redirect'; + +describe('sanitizeRedirectPath', () => { + it('keeps internal paths with search and hash fragments', () => { + expect(sanitizeRedirectPath('/settings?tab=profile#security')).toBe( + '/settings?tab=profile#security' + ); + }); + + it('rejects external redirects and auth loops', () => { + expect(sanitizeRedirectPath('https://example.com/steal', '/')).toBe('/'); + expect(sanitizeRedirectPath('/auth/login?redirect=/settings', '/')).toBe('/'); + expect(sanitizeRedirectPath('/auth/logout', '/')).toBe('/'); + }); +}); + +describe('buildLoginPath', () => { + it('includes the sanitized redirect and a recognized login reason', () => { + expect( + buildLoginPath({ + redirectTo: '/settings?tab=profile', + reason: 'auth-required' + }) + ).toBe('/auth/login?redirect=%2Fsettings%3Ftab%3Dprofile&reason=auth-required'); + }); + + it('drops invalid redirect values', () => { + expect( + buildLoginPath({ + redirectTo: 'https://example.com/phish', + reason: 'signed-out' + }) + ).toBe('/auth/login?reason=signed-out'); + }); +}); + +describe('withRedirectParam', () => { + it('adds the redirect query parameter only when it is safe', () => { + expect(withRedirectParam('/auth/create-account', '/settings')).toBe( + '/auth/create-account?redirect=%2Fsettings' + ); + expect(withRedirectParam('/auth/create-account', 'https://example.com')).toBe( + '/auth/create-account' + ); + }); +}); + +describe('readLoginReason', () => { + it('returns only supported reason values', () => { + expect(readLoginReason(new URLSearchParams('reason=expired'))).toBe('expired'); + expect(readLoginReason(new URLSearchParams('reason=unknown'))).toBeNull(); + }); +}); diff --git a/src/lib/utils/auth-redirect.ts b/src/lib/utils/auth-redirect.ts new file mode 100644 index 00000000..e0e98a6d --- /dev/null +++ b/src/lib/utils/auth-redirect.ts @@ -0,0 +1,102 @@ +export const LOGIN_REASONS = ['auth-required', 'expired', 'signed-out', 'password-reset', 'error-recovery'] as const; + +export type LoginReason = (typeof LOGIN_REASONS)[number]; + +const APP_ORIGIN = 'https://cropwatch.local'; +const ROOT_PATH = '/'; + +function isLoginReason(value: string | null | undefined): value is LoginReason { + return typeof value === 'string' && LOGIN_REASONS.includes(value as LoginReason); +} + +export function sanitizeRedirectPath( + value: string | null | undefined, + fallback: string = ROOT_PATH +): string { + if (typeof value !== 'string') return fallback; + + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith('//')) { + return fallback; + } + + try { + const url = new URL(trimmed, APP_ORIGIN); + if (url.origin !== APP_ORIGIN || !url.pathname.startsWith('/')) { + return fallback; + } + + const normalized = `${url.pathname}${url.search}${url.hash}`; + if (normalized.startsWith('/auth/login') || normalized.startsWith('/auth/logout')) { + return fallback; + } + + return normalized || fallback; + } catch { + return fallback; + } +} + +export function readRedirectPath( + searchParams: URLSearchParams, + fallback: string = ROOT_PATH +): string { + return sanitizeRedirectPath(searchParams.get('redirect'), fallback); +} + +export function readRedirectPathFromUrl(url: URL, fallback: string = ROOT_PATH): string { + return readRedirectPath(url.searchParams, fallback); +} + +export function readLoginReason(searchParams: URLSearchParams): LoginReason | null { + const rawReason = searchParams.get('reason'); + return isLoginReason(rawReason) ? rawReason : null; +} + +function withSearchParams(path: string, params: URLSearchParams): string { + const search = params.toString(); + return search ? `${path}?${search}` : path; +} + +export function withRedirectParam(path: string, redirectTo: string | null | undefined): string { + const params = new URLSearchParams(); + const sanitizedRedirect = sanitizeRedirectPath(redirectTo, ''); + + if (sanitizedRedirect) { + params.set('redirect', sanitizedRedirect); + } + + return withSearchParams(path, params); +} + +export function buildLoginPath( + options: { + path?: string; + redirectTo?: string | null | undefined; + reason?: LoginReason | null | undefined; + } = {} +): string { + const { path = '/auth/login', redirectTo, reason } = options; + const params = new URLSearchParams(); + const sanitizedRedirect = sanitizeRedirectPath(redirectTo, ''); + + if (sanitizedRedirect) { + params.set('redirect', sanitizedRedirect); + } + + if (reason && isLoginReason(reason)) { + params.set('reason', reason); + } + + return withSearchParams(path, params); +} + +export function buildLogoutPath( + options: { + path?: string; + redirectTo?: string | null | undefined; + } = {} +): string { + const { path = '/auth/logout', redirectTo } = options; + return withRedirectParam(path, redirectTo); +} diff --git a/src/lib/utils/recaptcha.server.ts b/src/lib/utils/recaptcha.server.ts new file mode 100644 index 00000000..87d61d11 --- /dev/null +++ b/src/lib/utils/recaptcha.server.ts @@ -0,0 +1,248 @@ +import { + PRIVATE_RECAPTCHA_PROJECT_ID, + PRIVATE_RECAPTCHA_SITE_KEY, + PRIVATE_RECAPTCHA_API_KEY +} from '$env/static/private'; +import { env as privateEnv } from '$env/dynamic/private'; + +export interface RecaptchaVerificationResult { + success: boolean; + score?: number; + action?: string; + error?: string; + reasons?: string[]; +} + +export type RecaptchaLogContext = { + route?: string; + flow?: string; + requestId?: string; + userAgent?: string; +}; + +interface AssessmentResponse { + tokenProperties?: { + valid?: boolean; + invalidReason?: string; + action?: string; + }; + riskAnalysis?: { + score?: number; + reasons?: string[]; + }; + error?: { + message?: string; + }; +} + +type LogLevel = 'silent' | 'error' | 'info' | 'debug'; + +function getLogLevel(): LogLevel { + const raw = (process.env.PRIVATE_RECAPTCHA_LOG_LEVEL ?? '').toLowerCase(); + if (raw === 'silent' || raw === 'error' || raw === 'info' || raw === 'debug') return raw; + return process.env.NODE_ENV === 'production' ? 'error' : 'info'; +} + +function getRecaptchaApiBaseUrl(): string { + return ( + privateEnv.PRIVATE_RECAPTCHA_API_BASE_URL ?? 'https://recaptchaenterprise.googleapis.com' + ).replace(/\/+$/, ''); +} + +function mask(value: string | null | undefined, keepStart = 6, keepEnd = 4): string { + if (!value) return ''; + const v = String(value); + if (v.length <= keepStart + keepEnd + 3) return ''; + return `${v.slice(0, keepStart)}…${v.slice(-keepEnd)}`; +} + +function log( + level: Exclude, + message: string, + meta: Record = {} +) { + const current = getLogLevel(); + const order: Record = { silent: 0, error: 1, info: 2, debug: 3 }; + if (order[current] < order[level]) return; + + const payload = { source: 'recaptcha', ...meta }; + if (level === 'error') console.error(message, payload); + else if (level === 'info') console.info(message, payload); + else console.debug(message, payload); +} + +/** + * Verify reCAPTCHA Enterprise token using the REST API + * @param token - The reCAPTCHA token from the client + * @param expectedAction - The expected action name (e.g., 'LOGIN', 'REGISTER') + * @param minScore - Minimum score threshold (0.0 to 1.0, default 0.5) + * @returns Promise resolving to verification result + */ +export async function verifyRecaptchaToken( + token: string, + expectedAction: string, + minScore: number = 0.5, + context: RecaptchaLogContext = {} +): Promise { + try { + if ( + !PRIVATE_RECAPTCHA_PROJECT_ID || + !PRIVATE_RECAPTCHA_API_KEY || + !PRIVATE_RECAPTCHA_SITE_KEY + ) { + log('error', 'reCAPTCHA server env is missing', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + projectId: mask(PRIVATE_RECAPTCHA_PROJECT_ID), + apiKey: mask(PRIVATE_RECAPTCHA_API_KEY), + siteKey: mask(PRIVATE_RECAPTCHA_SITE_KEY) + }); + return { + success: false, + error: 'Server reCAPTCHA configuration missing' + }; + } + + log('debug', 'Starting reCAPTCHA assessment', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + minScore, + tokenLength: token?.length ?? 0, + tokenPrefix: token ? mask(token, 12, 0) : '', + userAgent: context.userAgent + }); + + const url = `${getRecaptchaApiBaseUrl()}/v1/projects/${PRIVATE_RECAPTCHA_PROJECT_ID}/assessments?key=${PRIVATE_RECAPTCHA_API_KEY}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event: { + token: token, + siteKey: PRIVATE_RECAPTCHA_SITE_KEY, + expectedAction: expectedAction + } + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + log('error', 'reCAPTCHA API request failed', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + projectId: mask(PRIVATE_RECAPTCHA_PROJECT_ID), + apiKey: mask(PRIVATE_RECAPTCHA_API_KEY), + siteKey: mask(PRIVATE_RECAPTCHA_SITE_KEY), + status: response.status, + statusText: response.statusText, + errorText: errorText?.slice(0, 2000) // keep logs bounded + }); + return { + success: false, + error: `API error: ${response.status}` + }; + } + + const data: AssessmentResponse = await response.json(); + + if (data.error?.message) { + log('error', 'reCAPTCHA API responded with error payload', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + errorMessage: data.error.message + }); + } + + // Check if the token is valid + if (!data.tokenProperties?.valid) { + log('info', 'reCAPTCHA token invalid', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + invalidReason: data.tokenProperties?.invalidReason + }); + return { + success: false, + error: `Invalid token: ${data.tokenProperties?.invalidReason}` + }; + } + + // Check if the expected action was executed + if (data.tokenProperties.action !== expectedAction) { + log('info', 'reCAPTCHA action mismatch', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + gotAction: data.tokenProperties.action + }); + return { + success: false, + action: data.tokenProperties.action ?? undefined, + error: 'Action mismatch' + }; + } + + const score = data.riskAnalysis?.score ?? 0; + const reasons = data.riskAnalysis?.reasons ?? []; + + // Check if the score meets the threshold + if (score < minScore) { + log('info', 'reCAPTCHA score below threshold', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + score, + minScore, + reasons + }); + return { + success: false, + score, + action: data.tokenProperties.action ?? undefined, + reasons, + error: `Score too low: ${score}` + }; + } + + log('debug', 'reCAPTCHA assessment passed', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + score, + reasons + }); + + return { + success: true, + score, + action: data.tokenProperties.action ?? undefined, + reasons + }; + } catch (error) { + log('error', 'reCAPTCHA Enterprise verification threw', { + route: context.route, + flow: context.flow, + requestId: context.requestId, + expectedAction, + error: error instanceof Error ? error.message : String(error) + }); + return { + success: false, + error: error instanceof Error ? error.message : 'reCAPTCHA verification error' + }; + } +} diff --git a/src/lib/utils/recaptcha.ts b/src/lib/utils/recaptcha.ts new file mode 100644 index 00000000..ac5a8514 --- /dev/null +++ b/src/lib/utils/recaptcha.ts @@ -0,0 +1,268 @@ +import { env as publicEnv } from '$env/dynamic/public'; + +declare global { + interface Window { + grecaptcha?: { + enterprise?: { + ready: (callback: () => void) => void; + execute: (siteKey: string, options: { action: string }) => Promise; + }; + }; + } +} + +export const RECAPTCHA_ACTIONS = ['LOGIN', 'REGISTER', 'FORGOT_PASSWORD'] as const; + +export type RecaptchaAction = (typeof RECAPTCHA_ACTIONS)[number]; +export type RecaptchaStatus = 'idle' | 'loading' | 'ready' | 'error'; + +const SCRIPT_ID = 'cw-recaptcha-enterprise'; +const SCRIPT_SRC_MATCHER = 'script[src*="recaptcha/enterprise.js"]'; +const READY_TIMEOUT_MS = 8_000; +const EXECUTE_TIMEOUT_MS = 12_000; + +let status: RecaptchaStatus = 'idle'; +let inflightLoad: Promise | null = null; +let inflightReady: Promise | null = null; +let lastError: Error | null = null; + +function getPublicSiteKey(): string | null { + const key = (publicEnv.PUBLIC_RECAPTCHA_SITE_KEY ?? '').trim(); + return key.length > 0 ? key : null; +} + +function hasUsableEnterpriseApi(): boolean { + return Boolean( + typeof window !== 'undefined' && + window.grecaptcha?.enterprise && + typeof window.grecaptcha.enterprise.ready === 'function' && + typeof window.grecaptcha.enterprise.execute === 'function' + ); +} + +function setStatus(nextStatus: RecaptchaStatus, error: Error | null = null) { + status = nextStatus; + lastError = error; +} + +function toError(error: unknown, fallback: string): Error { + if (error instanceof Error) { + return error; + } + + return new Error(fallback); +} + +function getExistingScripts(): HTMLScriptElement[] { + if (typeof document === 'undefined') { + return []; + } + + return Array.from(document.querySelectorAll(SCRIPT_SRC_MATCHER)); +} + +function removeRecaptchaArtifacts() { + if (typeof document === 'undefined') { + return; + } + + document.querySelector('.grecaptcha-badge')?.parentElement?.remove(); + document.getElementById(SCRIPT_ID)?.remove(); + + for (const script of getExistingScripts()) { + script.remove(); + } +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + errorMessage: string +): Promise { + let timeoutId: number | undefined; + + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error(errorMessage)), timeoutMs); + }) + ]); + } finally { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + } +} + +async function waitForEnterpriseReady(): Promise { + if (inflightReady) { + return inflightReady; + } + + if (!hasUsableEnterpriseApi()) { + throw new Error('reCAPTCHA Enterprise API is unavailable'); + } + + inflightReady = withTimeout( + new Promise((resolve, reject) => { + try { + window.grecaptcha!.enterprise!.ready(() => { + resolve(); + }); + } catch (error) { + reject(error); + } + }), + READY_TIMEOUT_MS, + 'reCAPTCHA Enterprise did not become ready in time' + ).finally(() => { + inflightReady = null; + }); + + return inflightReady; +} + +async function loadRecaptchaScriptElement(siteKey: string): Promise { + const existingScripts = getExistingScripts(); + if (existingScripts.length > 0 && !hasUsableEnterpriseApi()) { + removeRecaptchaArtifacts(); + } + + if (hasUsableEnterpriseApi()) { + return; + } + + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`; + script.async = true; + script.defer = true; + + script.onload = () => { + resolve(); + }; + + script.onerror = () => { + script.remove(); + reject(new Error('reCAPTCHA script failed to load')); + }; + + document.head.appendChild(script); + }); +} + +async function ensureRecaptchaReady(): Promise { + const siteKey = getPublicSiteKey(); + if (!siteKey) { + const error = new Error('reCAPTCHA is not configured (missing PUBLIC_RECAPTCHA_SITE_KEY)'); + setStatus('error', error); + throw error; + } + + if (hasUsableEnterpriseApi() && status === 'ready') { + return; + } + + if (!inflightLoad) { + setStatus('loading'); + + inflightLoad = (async () => { + try { + await loadRecaptchaScriptElement(siteKey); + + if (!hasUsableEnterpriseApi()) { + throw new Error('reCAPTCHA script loaded without a usable Enterprise API'); + } + + await waitForEnterpriseReady(); + setStatus('ready'); + } catch (error) { + const normalizedError = toError(error, 'reCAPTCHA failed to initialize'); + removeRecaptchaArtifacts(); + setStatus('error', normalizedError); + throw normalizedError; + } finally { + inflightLoad = null; + } + })(); + } + + return inflightLoad; +} + +function resetInternalState(nextStatus: RecaptchaStatus = 'idle') { + inflightLoad = null; + inflightReady = null; + setStatus(nextStatus); +} + +function getExecuteFn() { + const execute = window.grecaptcha?.enterprise?.execute; + if (typeof execute !== 'function') { + throw new Error('reCAPTCHA Enterprise execute API is unavailable'); + } + + return execute; +} + +export function getRecaptchaStatus(): RecaptchaStatus { + return status; +} + +export function getRecaptchaError(): Error | null { + return lastError; +} + +export async function warmupRecaptcha(): Promise { + try { + await ensureRecaptchaReady(); + return true; + } catch { + return false; + } +} + +export async function runRecaptchaAction(action: RecaptchaAction): Promise { + await ensureRecaptchaReady(); + + const siteKey = getPublicSiteKey(); + if (!siteKey) { + const error = new Error('reCAPTCHA is not configured (missing PUBLIC_RECAPTCHA_SITE_KEY)'); + setStatus('error', error); + throw error; + } + + try { + const execute = getExecuteFn(); + const token = await withTimeout( + execute(siteKey, { action }), + EXECUTE_TIMEOUT_MS, + 'reCAPTCHA token generation timed out' + ); + + if (typeof token !== 'string' || token.length === 0) { + throw new Error('reCAPTCHA returned an empty token'); + } + + setStatus('ready'); + return token; + } catch (error) { + const normalizedError = toError(error, 'reCAPTCHA token generation failed'); + removeRecaptchaArtifacts(); + resetInternalState('error'); + lastError = normalizedError; + throw normalizedError; + } +} + +export function resetRecaptchaFailureState() { + removeRecaptchaArtifacts(); + delete window.grecaptcha; + resetInternalState(); +} + +export function unloadRecaptchaScript() { + resetRecaptchaFailureState(); +} diff --git a/src/lib/utils/strongPasswordCheck.ts b/src/lib/utils/strongPasswordCheck.ts new file mode 100644 index 00000000..3d2dfbb8 --- /dev/null +++ b/src/lib/utils/strongPasswordCheck.ts @@ -0,0 +1,43 @@ + +export interface IPasswordValidationResult { + hasMinLength: boolean; + hasUpperCase: boolean; + hasLowerCase: boolean; + hasNumber: boolean; + hasSymbol: boolean; + isValid: boolean; + errors: string[]; +} + +// Password must be at least 6 characters long, and contain at least one uppercase letter, one lowercase letter, one number, and one symbol. +export function isStrongPassword(password: string): IPasswordValidationResult { + const result: IPasswordValidationResult = { + hasMinLength: password.length >= 6, + hasUpperCase: /[A-Z]/.test(password), + hasLowerCase: /[a-z]/.test(password), + hasNumber: /[0-9]/.test(password), + hasSymbol: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password), + isValid: false, + errors: [] + }; + + result.isValid = result.hasMinLength && result.hasUpperCase && result.hasLowerCase && result.hasNumber && result.hasSymbol; + + if (!result.hasMinLength) { + result.errors.push('Password must be at least 6 characters long.'); + } + if (!result.hasUpperCase) { + result.errors.push('Password must contain at least one uppercase letter.'); + } + if (!result.hasLowerCase) { + result.errors.push('Password must contain at least one lowercase letter.'); + } + if (!result.hasNumber) { + result.errors.push('Password must contain at least one number.'); + } + if (!result.hasSymbol) { + result.errors.push('Password must contain at least one symbol.'); + } + + return result; +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index db47e68e..2d6f5eed 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -1,62 +1,100 @@ - - - {$_('Page Not Found')} | CropWatch - + import { browser } from '$app/environment'; + import { CwButton, CwCard } from '@cropwatchdevelopment/cwui'; + import { m } from '$lib/paraglide/messages.js'; -
    -
    -
    -
    - - - -
    + const errorMessages: Record = { + 400: { + title: m.error_bad_request_title(), + description: m.error_bad_request_description() + }, + 401: { + title: m.error_unauthorized_title(), + description: m.error_unauthorized_description() + }, + 403: { + title: m.error_forbidden_title(), + description: m.error_forbidden_description() + }, + 404: { + title: m.error_not_found_title(), + description: m.error_not_found_description() + }, + 408: { + title: m.error_timeout_title(), + description: m.error_timeout_description() + }, + 429: { + title: m.error_too_many_requests_title(), + description: m.error_too_many_requests_description() + }, + 500: { + title: m.error_internal_server_title(), + description: m.error_internal_server_description() + }, + 502: { + title: m.error_bad_gateway_title(), + description: m.error_bad_gateway_description() + }, + 503: { + title: m.error_service_unavailable_title(), + description: m.error_service_unavailable_description() + }, + 504: { + title: m.error_gateway_timeout_title(), + description: m.error_gateway_timeout_description() + } + }; -

    404

    -

    {$_('Page Not Found')}

    + const statusCode = $derived(page.error ? (page.status ?? 500) : 500); + const knownError = $derived(errorMessages[statusCode]); + const title = $derived(knownError?.title ?? m.error_unexpected_title()); + const description = $derived(page.error?.message ?? knownError?.description ?? m.error_unknown()); -

    - {$_( - 'The page you are looking for does not exist. It might have been moved, deleted, or you entered the wrong URL.' - )} -

    + function resetAndLogin() { + if (!browser) return; + try { localStorage.clear(); } catch { /* ignore */ } + try { sessionStorage.clear(); } catch { /* ignore */ } + try { + document.cookie.split(';').forEach((c) => { + const name = c.split('=')[0].trim(); + if (name) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + }); + } catch { /* ignore */ } + window.location.href = '/auth/login?reason=error-recovery'; + } + -
    - +
    + + {#snippet header()} +
    +
    + {#if statusCode == 500} + 💣 + {:else if statusCode == 404} + 🔍 + {:else if statusCode == 401} + 🔒 + {:else} + ⚠️ + {/if} + {statusCode} +
    +

    {title}

    + {/snippet} -
    - -
    +

    {description}

    + +
    + goto('/')}>{m.action_go_home()} + history.back()}>{m.action_go_back()} + {m.error_reset_and_login()}
    -
    +
    diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index a691d4c7..1df497e8 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,42 +1,20 @@ -import { redirect } from '@sveltejs/kit'; +import { ApiService } from '$lib/api/api.service'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async (event) => { - const { cookies, url, depends, locals } = event; +export const load: LayoutServerLoad = async ({ locals, fetch }) => { - // Register dependency on Supabase auth - this ensures the layout rerenders when auth state changes - depends('supabase:auth'); +const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken: locals.jwtString ?? null + }); - // Skip auth check for auth routes - const isAuthRoute = url.pathname.startsWith('/auth'); + const profile = await apiServiceInstance.getUserProfile().catch((error) => { + console.error('Failed to fetch authenticated user:', error); + }); - // Get all cookies to pass to the client - const cookieList = cookies.getAll(); - - // Get session and user from locals (already validated in hooks.server.ts) - const session = locals.session; - const user = locals.user; - - // // Added additional logging for debugging redirection logic - // if (!session?.user && !isAuthRoute) { - // console.warn('No session found, redirecting to login:', url.pathname); - // throw redirect(302, '/auth/login'); - // } - - // Return everything needed by the client except the Supabase client (which is not serializable) return { - session, - cookies: cookieList, - user: user - ? { - id: user.id, - email: user.email, - name: user.user_metadata?.name || user.email?.split('@')[0] || 'User' - } - : { - id: null, - email: 'Guest', - name: 'Guest' - } + session: locals.jwt ?? null, + authToken: locals.jwtString ?? null, + profile, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 83c74627..fbb8939c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,173 +1,198 @@ - -{#if i18n.initialized} -
    - {#if !page.url.pathname.startsWith('/auth')} -
    - {/if} - {#if showSidebar} - - {/if} -
    - {@render children?.()} - -
    - -
    -{/if} + + - + \ No newline at end of file diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts deleted file mode 100644 index 969c4acb..00000000 --- a/src/routes/+layout.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr' -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public' -import type { LayoutLoad } from './$types' - -export const load: LayoutLoad = async ({ fetch, data, depends }) => { - depends('supabase:auth') - const supabase = isBrowser() - ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { - fetch, - }, - }) - : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { - fetch, - }, - cookies: { - getAll() { - return data.cookies - }, - }, - }) - /** - * It's fine to use `getSession` here, because on the client, `getSession` is - * safe, and on the server, it reads `session` from the `LayoutData`, which - * safely checked the session using `safeGetSession`. - */ - const { - data: { session }, - } = await supabase.auth.getSession() - return { supabase, session: session, user: data.user } -} \ No newline at end of file diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index bd99c575..26f8d27e 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,14 +1,263 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { DevicePrimaryDataDto, LocationDto } from '$lib/api/api.dtos'; +import type { IDevice } from '$lib/interfaces/device.interface'; +import { + buildDeviceTypeLookup, + mapDashboardDeviceMetadataToDevice, + mapDashboardPrimaryDataToDevice, + mergeDashboardDevices +} from '$lib/components/dashboard/dashboard-device-data'; +import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashboard-filter-values'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const { count, error } = await supabase - .from('cw_devices') - .select('dev_eui', { count: 'exact', head: true }); +const LATEST_PRIMARY_DATA_PAGE_SIZE = 25; +function serializeError(error: unknown): Record { + if (error instanceof ApiServiceError) { + return { + name: error.name, + message: error.message, + status: error.status, + payload: error.payload + }; + } + + if (error instanceof Error) { + return { + name: error.name, + message: error.message + }; + } + + return { + message: String(error) + }; +} + +async function getAllLatestPrimaryDeviceData( + apiServiceInstance: ApiService +): Promise { + const firstPage = await apiServiceInstance.getLatestPrimaryDeviceData({ + skip: 0, + take: LATEST_PRIMARY_DATA_PAGE_SIZE + }); + + const firstBatch = firstPage.data ?? []; + const total = + typeof firstPage.total === 'number' && Number.isFinite(firstPage.total) + ? Math.max(0, firstPage.total) + : firstBatch.length; + + const allDevices = [...firstBatch]; + let skip = LATEST_PRIMARY_DATA_PAGE_SIZE; + + while (skip < total) { + const nextPage = await apiServiceInstance.getLatestPrimaryDeviceData({ + skip, + take: LATEST_PRIMARY_DATA_PAGE_SIZE + }); + const nextBatch = nextPage.data ?? []; - if (error) { - console.error('Error checking devices:', error); - return { hasDevices: false }; + if (nextBatch.length === 0) { + break; + } + + allDevices.push(...nextBatch); + skip += LATEST_PRIMARY_DATA_PAGE_SIZE; + } + + return allDevices; +} + +function buildDerivedLocations(latestPrimaryData: DevicePrimaryDataDto[]): LocationDto[] { + const locationMap = new Map(); + + for (const device of latestPrimaryData) { + const locationId = Number(device.location_id); + const locationName = + typeof device.location_name === 'string' ? device.location_name.trim() : ''; + + if (!Number.isFinite(locationId) || locationId <= 0 || !locationName) { + continue; + } + + if (!locationMap.has(locationId)) { + locationMap.set(locationId, locationName); + } } - return { hasDevices: Boolean(count && count > 0) }; + return Array.from(locationMap.entries()) + .sort( + ([leftId, leftName], [rightId, rightName]) => + leftName.localeCompare(rightName) || leftId - rightId + ) + .map( + ([location_id, name]) => + ({ + location_id, + name, + created_at: new Date(0).toISOString() + }) as LocationDto + ); +} + +async function loadDashboardLatestPrimaryData(apiServiceInstance: ApiService): Promise<{ + value: DevicePrimaryDataDto[]; + error: Record | null; + source: 'bulk' | 'empty'; + skippedDevEuis: string[]; +}> { + try { + return { + value: await getAllLatestPrimaryDeviceData(apiServiceInstance), + error: null, + source: 'bulk', + skippedDevEuis: [] + }; + } catch (bulkError) { + console.error('Failed to load latest primary data', bulkError); + return { + value: [], + error: serializeError(bulkError), + source: 'empty', + skippedDevEuis: [] + }; + } +} + +async function loadDashboardPayload(apiServiceInstance: ApiService) { + const [ + latestPrimaryDataResult, + devicesResult, + deviceTypesResult, + triggeredRules, + triggeredRulesCount, + deviceStatusesResult, + deviceGroupsResult, + locationsResult, + locationGroupsResult + ] = await Promise.all([ + loadDashboardLatestPrimaryData(apiServiceInstance), + apiServiceInstance + .getAllDevices() + .then((value) => ({ value: value ?? [], error: null as Record | null })) + .catch((error) => ({ value: [], error: serializeError(error) })), + apiServiceInstance + .getDeviceTypes() + .then((value) => ({ value: value ?? [], error: null as Record | null })) + .catch((error) => ({ value: [], error: serializeError(error) })), + apiServiceInstance.getTriggeredRules().catch(() => []), + apiServiceInstance.getTriggeredRulesCount().catch(() => 0), + apiServiceInstance + .getDeviceStatuses() + .then((value) => ({ value, error: null as Record | null })) + .catch((error) => ({ + value: { online: 0, offline: 0 }, + error: serializeError(error) + })), + apiServiceInstance + .getDeviceGroups() + .then((value) => ({ value: value ?? [], error: null as Record | null })) + .catch((error) => ({ value: [], error: serializeError(error) })), + apiServiceInstance + .getLocations() + .then((value) => ({ value, error: null as Record | null })) + .catch((error) => ({ value: [], error: serializeError(error) })), + apiServiceInstance + .getLocationGroups() + .then((value) => ({ value, error: null as Record | null })) + .catch((error) => ({ value: [], error: serializeError(error) })) + ]); + + const latestPrimaryData = latestPrimaryDataResult.value; + const allDevices = devicesResult.value; + const deviceStatuses = deviceStatusesResult.value; + const locations = locationsResult.value; + const locationGroups = normalizeDashboardFilterValues(locationGroupsResult.value); + + // ── Build device type lookup from dedicated endpoint ── + const deviceTypeLookup = buildDeviceTypeLookup(deviceTypesResult.value); + const metadataDevices = allDevices.map((device) => mapDashboardDeviceMetadataToDevice(device)); + const primaryDataDevices = latestPrimaryData.map((device) => mapDashboardPrimaryDataToDevice(device)); + + const devices: IDevice[] = mergeDashboardDevices(metadataDevices, primaryDataDevices); + + const groups = normalizeDashboardFilterValues(devices.map((device) => device.group)); + const deviceGroups = normalizeDashboardFilterValues(deviceGroupsResult.value); + + const resolvedDeviceGroups = deviceGroups.length > 0 ? deviceGroups : groups; + const derivedLocations = buildDerivedLocations(latestPrimaryData); + const resolvedLocations = locations.length > 0 ? locations : derivedLocations; + + return { + devices, + deviceTypeLookup, + totalDeviceCount: devices.length, + triggeredRules, + triggeredRulesCount, + deviceStatuses, + deviceGroups: resolvedDeviceGroups, + locations: resolvedLocations, + locationGroups, + dashboardDebug: { + latestPrimaryData: { + count: latestPrimaryData.length, + error: latestPrimaryDataResult.error, + source: latestPrimaryDataResult.source, + skippedDevEuis: latestPrimaryDataResult.skippedDevEuis + }, + deviceStatuses: { + value: deviceStatuses, + error: deviceStatusesResult.error + }, + deviceGroups: { + count: resolvedDeviceGroups.length, + error: deviceGroupsResult.error + }, + devices: { + count: devices.length, + error: devicesResult.error + }, + locations: { + count: resolvedLocations.length, + error: locationsResult.error + }, + locationGroups: { + count: locationGroups.length, + error: locationGroupsResult.error + } + } + }; +} + +const EMPTY_DASHBOARD = { + devices: [] as IDevice[], + totalDeviceCount: 0, + triggeredRules: [] as unknown[], + triggeredRulesCount: 0, + deviceStatuses: { online: 0, offline: 0 }, + deviceGroups: [] as string[], + locations: [] as LocationDto[], + locationGroups: [] as string[], + dashboardDebug: null as Record | null +}; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + const authToken = locals.jwtString ?? null; + + if (!authToken) { + return { + authToken: null, + dashboard: EMPTY_DASHBOARD + }; + } + + const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken + }); + + return { + authToken, + dashboard: loadDashboardPayload(apiServiceInstance) + }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1f6236f3..5b0e1d78 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,225 @@ - - {$_('Login')} - + + {m.dashboard_page_title()} + + +
    +
    +
    +
    + + +
    + setDashboardView('table')} + > + + {m.dashboard_table_view()} + + setDashboardView('sensor-cards')} + > + + {m.dashboard_sensor_cards_view()} + + + {#if dashboardView === 'sensor-cards'} +
    + setCardLayout('grid')} + > + + + setCardLayout('masonry')} + > + + +
    + {/if} +
    +
    +
    +
    + + {#if dashboardLoading} +
    +
    +
    + +
    + {m.dashboard_loading_devices()} +
    +
    + {:else if dashboardViewReady} + {#if dashboardView === 'sensor-cards'} + + {:else} + + {/if} + {:else} +
    +

    {m.dashboard_loading_view()}

    +
    + {/if} +
    diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 00000000..d43d0cd2 --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1 @@ +export const prerender = false; diff --git a/src/routes/Header.svelte b/src/routes/Header.svelte new file mode 100644 index 00000000..c8e38443 --- /dev/null +++ b/src/routes/Header.svelte @@ -0,0 +1,88 @@ + + + (mode = mode === 'hidden' ? 'open' : 'hidden')} +> + {#snippet logo()} + {#if mode === 'mini'} + +
    + CropWatch Logo + 𝘾𝙧𝙤𝙥𝙒𝙖𝙩𝙘𝙝® UI +
    + {/if} + {/snippet} + + {#snippet actions()} + + + { + if (event.id === 'logout') { + Object.assign(app, defaultAppContext); + window.location.href = buildLogoutPath({ + path: resolve('/auth/logout'), + redirectTo: `${page.url.pathname}${page.url.search}` + }); + } else if (event.id === 'profile') { + goto(resolve('/account/profile')); + } else if (event.id === 'settings') { + goto(resolve('/settings')); + } else if (event.id === 'billing') { + goto(resolve('/account/billing')); + } + }} + /> + {/snippet} +
    + + diff --git a/src/routes/OverviewDrawer.svelte b/src/routes/OverviewDrawer.svelte new file mode 100644 index 00000000..15267427 --- /dev/null +++ b/src/routes/OverviewDrawer.svelte @@ -0,0 +1,186 @@ + + + +
    +
    + s + x.value, 0)) + })} + > +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + +
    + {#if alerts.length === 0} + +

    {m.common_not_available()}

    +
    + {:else} +
    + {#each alerts as alert (alert.id)} +
    + 🔔 + {alert.name} + {alert.reported} +
    + {/each} +
    + {/if} +
    +
    +
    +
    +
    + + diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte new file mode 100644 index 00000000..d819093a --- /dev/null +++ b/src/routes/Sidebar.svelte @@ -0,0 +1,260 @@ + + + + {#snippet header()} +
    + {m.app_name()} + 𝘾𝙧𝙤𝙥𝙒𝙖𝙩𝙘𝙝® +
    + {/snippet} + + {#snippet aboveContent()} + {#if page.url.pathname === '/'} + +
    {m.sidebar_filter_devices_in_view()}
    +
    + applyFilter('group', item.value)} + /> + + applyFilter('locationGroup', item.value)} + /> + + applyFilter('location', item.value)} + /> +
    +
    + {/if} + {/snippet} + {#snippet footer()} + +
    + {#if app.session?.exp} +

    + {m.sidebar_current_session_expires()}: { + document.location = buildLogoutPath({ + path: resolve('/auth/logout'), + redirectTo: `${page.url.pathname}${page.url.search}` + }); + }} + /> +

    + {/if} +
    +
    + {/snippet} +
    + + diff --git a/src/routes/account/billing/+page.server.ts b/src/routes/account/billing/+page.server.ts new file mode 100644 index 00000000..b543859b --- /dev/null +++ b/src/routes/account/billing/+page.server.ts @@ -0,0 +1,542 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import { formatCurrency } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; +import { buildLoginPath } from '$lib/utils/auth-redirect'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +type UnknownRecord = Record; + +type BillingProduct = { + id: string; + name: string; + description: string; + priceLabel: string; + billingLabel: string; + active: boolean; +}; + +type BillingSubscription = { + id: string; + status: string; + productName: string; + startedAt: string | null; + renewsAt: string | null; + canceledAt: string | null; + amountLabel: string; +}; + +type BillingState = { + status: string; + hasActiveSubscription: boolean; + customerId: string | null; + activeSubscriptionCount: number; + trialSubscriptionCount: number; + cancelledSubscriptionCount: number; +}; + +type EndpointResult = { + data: unknown; + error: string | null; +}; + +const isRecord = (value: unknown): value is UnknownRecord => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asString = (value: unknown): string | null => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +}; + +const asNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const asBoolean = (value: unknown): boolean | null => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + return null; +}; + +const getPathValue = (record: UnknownRecord, path: string[]): unknown => { + let current: unknown = record; + for (const segment of path) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +}; + +const getPathFromUnknown = (value: unknown, path: string): unknown => { + const segments = path.split('.'); + let current: unknown = value; + for (const segment of segments) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +}; + +const toIsoStringOrNull = (value: unknown): string | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + const fromNumber = new Date(value); + return Number.isNaN(fromNumber.getTime()) ? null : fromNumber.toISOString(); + } + const text = asString(value); + if (!text) return null; + const parsed = new Date(text); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +}; + +const extractRecords = (payload: unknown, keys: string[]): UnknownRecord[] => { + if (Array.isArray(payload)) { + return payload.filter(isRecord); + } + if (!isRecord(payload)) { + return []; + } + + for (const key of keys) { + const value = getPathFromUnknown(payload, key); + if (Array.isArray(value)) { + const records = value.filter(isRecord); + if (records.length > 0) return records; + } + } + + return []; +}; + +const extractPriceRecord = (source: UnknownRecord): UnknownRecord | null => { + if (Array.isArray(source.prices)) { + const firstPrice = source.prices.find(isRecord); + if (firstPrice) return firstPrice; + } + if (isRecord(source.price)) return source.price; + if (isRecord(source.product_price)) return source.product_price; + return null; +}; + +const extractPriceLabel = (source: UnknownRecord): { priceLabel: string; billingLabel: string } => { + const price = extractPriceRecord(source); + const amountMinor = + asNumber(price?.price_amount) ?? + asNumber(price?.amount) ?? + asNumber(source.price_amount) ?? + asNumber(source.amount); + const currency = + asString(price?.price_currency) ?? + asString(price?.currency) ?? + asString(source.price_currency) ?? + asString(source.currency) ?? + 'USD'; + const interval = + asString(price?.recurring_interval) ?? + asString(price?.interval) ?? + asString(source.recurring_interval) ?? + asString(source.interval); + + const priceLabel = + amountMinor === null ? m.billing_custom_pricing() : formatCurrency(amountMinor / 100, currency); + const billingLabel = interval ? m.billing_billed_interval({ interval }) : m.billing_one_time(); + return { priceLabel, billingLabel }; +}; + +const normalizeProducts = (payload: unknown): BillingProduct[] => { + const items = extractRecords(payload, [ + 'items', + 'products', + 'data', + 'result', + 'result.items', + 'result.products', + 'data.items', + 'data.products' + ]); + return items.flatMap((item) => { + const base = isRecord(item.product) ? item.product : item; + const id = + asString(base.id) ?? + asString(base.product_id) ?? + asString(base.productId) ?? + asString(base.uuid) ?? + asString(item.id); + if (!id) return []; + + const name = + asString(base.name) ?? + asString(base.title) ?? + asString(base.display_name) ?? + asString(getPathValue(base, ['metadata', 'name'])) ?? + asString(getPathValue(item, ['metadata', 'name'])) ?? + id; + const description = + asString(base.description) ?? + asString(getPathValue(base, ['metadata', 'description'])) ?? + asString(getPathValue(item, ['metadata', 'description'])) ?? + ''; + const active = + (asBoolean(base.active) ?? asBoolean(item.active) ?? true) && + !(asBoolean(base.archived) ?? asBoolean(item.archived) ?? false) && + !(asBoolean(base.is_archived) ?? asBoolean(item.is_archived) ?? false); + const pricing = extractPriceLabel(isRecord(item.product) ? { ...base, ...item } : item); + + return [ + { + id, + name, + description, + priceLabel: pricing.priceLabel, + billingLabel: pricing.billingLabel, + active + } + ]; + }); +}; + +const normalizeSubscriptions = (payload: unknown): BillingSubscription[] => { + const items = extractRecords(payload, [ + 'items', + 'subscriptions', + 'data', + 'result', + 'result.items', + 'result.subscriptions', + 'data.items', + 'data.subscriptions' + ]); + return items.flatMap((item) => { + const id = asString(item.id) ?? asString(item.subscription_id) ?? asString(item.subscriptionId); + if (!id) return []; + + const status = + asString(item.status) ?? + asString(item.state) ?? + asString(item.subscription_status) ?? + m.billing_status_unknown(); + const productName = + asString(getPathValue(item, ['product', 'name'])) ?? + asString(getPathValue(item, ['product', 'display_name'])) ?? + asString(getPathValue(item, ['price', 'product', 'name'])) ?? + asString(getPathValue(item, ['product_price', 'product', 'name'])) ?? + asString(item.product_name) ?? + asString(item.productName) ?? + m.billing_unknown_plan(); + const startedAt = + toIsoStringOrNull(item.started_at) ?? + toIsoStringOrNull(item.start_date) ?? + toIsoStringOrNull(item.current_period_start); + const renewsAt = + toIsoStringOrNull(item.current_period_end) ?? + toIsoStringOrNull(item.renews_at) ?? + toIsoStringOrNull(item.ends_at); + const canceledAt = + toIsoStringOrNull(item.canceled_at) ?? + toIsoStringOrNull(item.cancel_at) ?? + toIsoStringOrNull(item.revoked_at); + const pricing = extractPriceLabel(item); + + return [ + { + id, + status, + productName, + startedAt, + renewsAt, + canceledAt, + amountLabel: pricing.priceLabel + } + ]; + }); +}; + +const normalizeState = (payload: unknown): BillingState => { + const state = isRecord(payload) ? payload : {}; + const activeSubscriptionCount = + asNumber(state.active_subscription_count) ?? asNumber(state.activeSubscriptionCount) ?? 0; + const trialSubscriptionCount = + asNumber(state.trial_subscription_count) ?? asNumber(state.trialSubscriptionCount) ?? 0; + const cancelledSubscriptionCount = + asNumber(state.cancelled_subscription_count) ?? asNumber(state.cancelledSubscriptionCount) ?? 0; + const hasActiveSubscription = + asBoolean(state.has_active_subscription) ?? + asBoolean(state.hasActiveSubscription) ?? + activeSubscriptionCount + trialSubscriptionCount > 0; + const customerId = + asString(state.customer_id) ?? + asString(state.customerId) ?? + asString(getPathValue(state, ['customer', 'id'])); + const status = asString(state.status) ?? m.billing_status_unknown(); + + return { + status, + hasActiveSubscription, + customerId, + activeSubscriptionCount, + trialSubscriptionCount, + cancelledSubscriptionCount + }; +}; + +const readApiError = (payload: unknown, fallback: string): string => { + if (isRecord(payload)) { + const message = payload.message; + if (Array.isArray(message)) { + const fromArray = message.map(asString).filter(Boolean).join(', '); + if (fromArray.length > 0) return fromArray; + } + const fromField = asString(message); + if (fromField) return fromField; + } + return fallback; +}; + +const executeApiRequest = async ( + request: () => Promise, + fallback: string +): Promise => { + try { + return { data: await request(), error: null }; + } catch (error) { + return { + data: null, + error: + error instanceof ApiServiceError + ? readApiError(error.payload, fallback) + : error instanceof Error + ? error.message + : fallback + }; + } +}; + +const findRedirectUrl = (payload: unknown): string | null => { + if (typeof payload === 'string' && /^https?:\/\//i.test(payload)) { + return payload; + } + if (Array.isArray(payload)) { + for (const item of payload) { + const fromArray = findRedirectUrl(item); + if (fromArray) return fromArray; + } + return null; + } + if (!isRecord(payload)) return null; + + const directKeys = [ + 'url', + 'checkout_url', + 'checkoutUrl', + 'portal_url', + 'portalUrl', + 'customer_portal_url', + 'customerPortalUrl' + ]; + for (const key of directKeys) { + const candidate = asString(payload[key]); + if (candidate && /^https?:\/\//i.test(candidate)) return candidate; + } + + for (const value of Object.values(payload)) { + const nested = findRedirectUrl(value); + if (nested) return nested; + } + return null; +}; + +const requireAuth = (jwt: string | null, redirectTarget: string) => { + if (!jwt) { + throw redirect( + 303, + buildLoginPath({ + redirectTo: redirectTarget, + reason: 'auth-required' + }) + ); + } +}; + +export const load: PageServerLoad = async ({ fetch, locals, url }) => { + requireAuth(locals.jwtString, `${url.pathname}${url.search}`); + const api = new ApiService({ + fetchFn: fetch, + authToken: locals.jwtString + }); + + const [productsResult, subscriptionsResult, stateResult] = await Promise.all([ + executeApiRequest(() => api.getPaymentsProducts(), m.billing_load_products_failed()), + executeApiRequest(() => api.getPaymentsSubscriptions(), m.billing_load_subscriptions_failed()), + executeApiRequest(() => api.getPaymentsSubscriptionState(), m.billing_load_state_failed()) + ]); + + const subscriptions = normalizeSubscriptions(subscriptionsResult.data); + + return { + products: normalizeProducts(productsResult.data), + subscriptions, + subscriptionState: normalizeState(stateResult.data), + errors: { + products: productsResult.error, + subscriptions: subscriptionsResult.error, + state: stateResult.error + } + }; +}; + +export const actions: Actions = { + createCheckoutSession: async ({ request, fetch, url, locals }) => { + requireAuth(locals.jwtString, `${url.pathname}${url.search}`); + const api = new ApiService({ + fetchFn: fetch, + authToken: locals.jwtString + }); + + const formData = await request.formData(); + const products = formData + .getAll('products') + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); + + if (products.length === 0) { + return fail(400, { + action: 'checkout', + message: m.billing_select_product_before_checkout() + }); + } + + const customerName = asString(formData.get('customer_name')); + const customerEmail = asString(formData.get('customer_email')); + const allowDiscountCodes = asBoolean(formData.get('allow_discount_codes')) ?? true; + const allowTrial = asBoolean(formData.get('allow_trial')) ?? true; + const returnUrl = asString(formData.get('return_url')) ?? `${url.origin}/account/billing`; + const successUrl = + asString(formData.get('success_url')) ?? `${url.origin}/account/billing?checkout=success`; + + const payload: UnknownRecord = { + products, + return_url: returnUrl, + success_url: successUrl, + allow_discount_codes: allowDiscountCodes, + allow_trial: allowTrial + }; + if (customerName) payload.customer_name = customerName; + if (customerEmail) payload.customer_email = customerEmail; + + let responsePayload: unknown; + try { + responsePayload = await api.createPaymentsCheckoutSession(payload); + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'checkout', + message: readApiError( + error instanceof ApiServiceError ? error.payload : error, + m.billing_checkout_session_failed() + ) + }); + } + + const redirectUrl = findRedirectUrl(responsePayload); + if (!redirectUrl) { + return fail(502, { + action: 'checkout', + message: m.billing_checkout_redirect_missing() + }); + } + + return { + action: 'checkout', + message: m.billing_checkout_session_created(), + redirectUrl + }; + }, + + createPortalSession: async ({ request, fetch, url, locals }) => { + requireAuth(locals.jwtString, `${url.pathname}${url.search}`); + const api = new ApiService({ + fetchFn: fetch, + authToken: locals.jwtString + }); + + const formData = await request.formData(); + const returnUrl = asString(formData.get('return_url')) ?? `${url.origin}/account/billing`; + const payload = { return_url: returnUrl }; + + let responsePayload: unknown; + try { + responsePayload = await api.createPaymentsPortalSession(payload); + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'portal', + message: readApiError( + error instanceof ApiServiceError ? error.payload : error, + m.billing_portal_open_failed() + ) + }); + } + + const redirectUrl = findRedirectUrl(responsePayload); + if (!redirectUrl) { + return fail(502, { + action: 'portal', + message: m.billing_portal_redirect_missing() + }); + } + + return { + action: 'portal', + message: m.billing_portal_session_created(), + redirectUrl + }; + }, + + cancelSubscription: async ({ request, fetch, locals, url }) => { + requireAuth(locals.jwtString, `${url.pathname}${url.search}`); + const api = new ApiService({ + fetchFn: fetch, + authToken: locals.jwtString + }); + + const formData = await request.formData(); + const subscriptionId = asString(formData.get('subscription_id')); + + if (!subscriptionId) { + return fail(400, { + action: 'cancel', + message: m.billing_subscription_id_required() + }); + } + + try { + await api.cancelPaymentsSubscription(subscriptionId); + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'cancel', + message: readApiError( + error instanceof ApiServiceError ? error.payload : error, + m.billing_subscription_cancel_failed() + ) + }); + } + + return { + action: 'cancel', + message: m.billing_subscription_canceled(), + cancelledId: subscriptionId + }; + } +}; diff --git a/src/routes/account/billing/+page.svelte b/src/routes/account/billing/+page.svelte new file mode 100644 index 00000000..0c821ed0 --- /dev/null +++ b/src/routes/account/billing/+page.svelte @@ -0,0 +1,814 @@ + + + + {m.nav_billing()} - {m.app_name()} + + + +
    +
    +
    +

    {m.billing_heading()}

    +

    {m.billing_subtitle()}

    +
    +
    + + {#if subscriptionState.customerId} + + {subscriptionState.customerId} + + {/if} +
    +
    + +
    + +

    {formatNumber(subscriptionState.activeSubscriptionCount)}

    +
    + + +

    {formatNumber(subscriptionState.trialSubscriptionCount)}

    +
    + + +

    + {formatNumber(subscriptionState.cancelledSubscriptionCount)} +

    +
    + + +

    {formatNumber(products.length)}

    +
    +
    + + {#if hasLoadErrors} + +

    {m.billing_api_warnings_subtitle()}

    +
      + {#if data.errors?.products} +
    • {m.billing_error_products({ message: data.errors.products })}
    • + {/if} + {#if data.errors?.subscriptions} +
    • {m.billing_error_subscriptions({ message: data.errors.subscriptions })}
    • + {/if} + {#if data.errors?.state} +
    • {m.billing_error_state({ message: data.errors.state })}
    • + {/if} +
    +
    + {/if} + +
    +
    + +
    + +
    + {#if visibleProducts.length === 0} + +

    {m.billing_no_products()}

    +
    + {:else} + {#each visibleProducts as product (product.id)} +
    +
    +
    +

    {product.name}

    +

    {product.description || m.billing_no_product_description()}

    +
    + +
    + +
    + + {#if !product.active} + + {/if} +
    + + toggleProductSelection(product.id, checked)} + /> + + {#if isProductSelected(product.id)} + + {/if} +
    + {/each} + {/if} +
    + +
    + (showArchivedProducts = checked)} + /> + + {m.billing_clear_selection()} + +
    + + +
    + (customerName = (event.target as HTMLInputElement).value)} + /> + (customerEmail = (event.target as HTMLInputElement).value)} + /> + (allowDiscountCodes = checked)} + /> + (allowTrial = checked)} + /> +
    +
    + + + + {#if customerName} + + {/if} + {#if customerEmail} + + {/if} + +
    + {m.billing_selected_count({ + count: formatNumber(selectedProductIds.length) + })} + + + {m.billing_launch_checkout()} + + +
    +
    +
    +
    +
    + + + + +
    + {m.billing_open_portal()} +
    +
    + + + {#snippet cell( + row: BillingSubscription, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'status'} + + {:else if col.key === 'startedAt'} + {formatDate(row.startedAt)} + {:else if col.key === 'renewsAt'} + {formatDate(row.renewsAt)} + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet rowActions(row: BillingSubscription)} + {#if row.status.toLowerCase().includes('cancel') || row.status + .toLowerCase() + .includes('revoke')} + + {:else} + openCancelDialog(row)}>{m.action_cancel()} + {/if} + {/snippet} + +
    +
    +
    +
    +
    + + + {#if subscriptionToCancel} + +

    + {m.billing_cancel_subscription_body({ + productName: subscriptionToCancel.productName, + id: subscriptionToCancel.id + })} +

    +

    + {m.billing_cancel_subscription_warning()} +

    +
    + {/if} + + {#snippet actions()} + + {m.billing_keep_subscription()} +
    + + + {m.billing_confirm_cancel()} + +
    +
    + {/snippet} +
    + + diff --git a/src/routes/account/profile/+page.server.ts b/src/routes/account/profile/+page.server.ts new file mode 100644 index 00000000..1e4ad365 --- /dev/null +++ b/src/routes/account/profile/+page.server.ts @@ -0,0 +1,37 @@ +import type { Profile } from '$lib/interfaces/profile.interface'; +import type { PageServerLoad } from './$types'; + +const readString = (value: unknown): string => { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +}; + +const emailToUsername = (email: string): string => { + const [localPart = ''] = email.split('@'); + return localPart.trim(); +}; + +export const load: PageServerLoad = async ({ locals }) => { + const session = locals.jwt ?? null; + const email = readString(session?.email); + const fullName = readString(session?.user_metadata?.full_name); + const usernameFromMetadata = readString(session?.user_metadata?.name); + const phoneNumber = readString(session?.phone); + + const profile: Profile = { + username: usernameFromMetadata || emailToUsername(email), + full_name: fullName, + website: '', + employer: '', + phone_number: phoneNumber + }; + + return { + email: email || null, + role: readString(session?.role) || null, + profile + }; +}; diff --git a/src/routes/account/profile/+page.svelte b/src/routes/account/profile/+page.svelte new file mode 100644 index 00000000..d457743f --- /dev/null +++ b/src/routes/account/profile/+page.svelte @@ -0,0 +1,352 @@ + + + + {m.nav_profile()} - CropWatch + + + +
    + + {#snippet actions()} +
    + {#if data.email} + + {/if} + {#if data.role} + + {/if} +
    + {/snippet} + + +
    +
    +

    Account profile

    +

    {displayName}

    +

    + Use this page to shape how your identity appears anywhere CropWatch surfaces profile + data. +

    +
    + +
    +
    + Employer + {employer || 'Add your company'} +
    +
    + Website + {website || 'Add your website'} +
    +
    + Phone + {phoneNumber || 'Add your phone number'} +
    +
    +
    +
    +
    + + {#if hasWebsiteError} + +

    Enter a valid domain or URL before saving this profile preview.

    +
    + {/if} + +
    event.preventDefault()}> +
    + + + + + + + + + + + + + + + + + +
    + + + +
    + {#if username} + + {/if} + {#if employer} + + {/if} + {#if website} + + {/if} +
    + + + + Reset + + + Save profile + + +
    +
    +
    +
    +
    + + diff --git a/src/routes/account/update-password/+page.server.ts b/src/routes/account/update-password/+page.server.ts deleted file mode 100644 index b706e70a..00000000 --- a/src/routes/account/update-password/+page.server.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { AuthService } from '$lib/services/AuthService'; - -// Make sure users are authenticated to access this page -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - // Create services directly - const errorHandler = new ErrorHandlingService(); - // Create AuthService with the request's Supabase client - const authService = new AuthService(supabase, errorHandler); - const session = await authService.getSession(); - // If not logged in, redirect to login page - if (!session || (session && !session.user)) { - throw redirect(302, '/auth/login'); - } - - return {}; -}; - -export const actions: Actions = { - default: async ({ request, locals: { supabase } }) => { - try { - // Check if user is authenticated - const errorHandler = new ErrorHandlingService(); - // Create AuthService with the request's Supabase client - const authService = new AuthService(supabase, errorHandler); - const session = await authService.getSession(); - if (!session) { - return fail(401, { error: 'You must be logged in to update your password' }); - } - - // Parse form data - const formData = await request.formData(); - const newPassword = formData.get('newPassword')?.toString() || ''; - const confirmPassword = formData.get('confirmPassword')?.toString() || ''; - - if (newPassword.length < 8) { - return fail(400, { error: 'New password must be at least 8 characters long' }); - } - - if (newPassword !== confirmPassword) { - return fail(400, { error: "New passwords don't match" }); - } - - // Update the password - const result = await authService.updatePassword(newPassword); - - if (!result.success) { - return fail(400, { error: result.error || 'Failed to update password' }); - } - - return { success: true }; - } catch (error) { - console.error('Password update error:', error); - return fail(500, { error: 'An unexpected error occurred. Please try again later.' }); - } - } -}; diff --git a/src/routes/account/update-password/+page.svelte b/src/routes/account/update-password/+page.svelte deleted file mode 100644 index c01122f8..00000000 --- a/src/routes/account/update-password/+page.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - - パスワードを更新 | CropWatch - - -
    -
    -

    パスワードを更新

    - - {#if form?.success} -
    -

    パスワードが正常に更新されました。

    -
    - {/if} - -
    -
    - - validatePasswords()} - class="text-text-light dark:text-text-dark focus:ring-primary w-full rounded-md border border-gray-300 - bg-white px-3 py-2 focus:border-transparent - focus:ring-2 focus:outline-none dark:border-gray-700 dark:bg-gray-800" - /> - {#if !isStrongPassword && newPassword.length > 0} -

    - パスワードは8文字以上である必要があります -

    - {/if} -
    - -
    - - validatePasswords()} - class="text-text-light dark:text-text-dark focus:ring-primary w-full rounded-md border border-gray-300 - bg-white px-3 py-2 focus:border-transparent - focus:ring-2 focus:outline-none dark:border-gray-700 dark:bg-gray-800" - /> - {#if !passwordsMatch && confirmPassword.length > 0} -

    パスワードが一致しません

    - {/if} -
    - - {#if form?.error && !form?.success} -
    - {form.error} -
    - {/if} - -
    - -
    -
    -
    -
    diff --git a/src/routes/api/+page.svelte b/src/routes/api/+page.svelte deleted file mode 100644 index ec2c45d1..00000000 --- a/src/routes/api/+page.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - SwaggerUI - - -
    - - diff --git a/src/routes/api/auth/google/+server.ts b/src/routes/api/auth/google/+server.ts deleted file mode 100644 index 87bbf414..00000000 --- a/src/routes/api/auth/google/+server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { json, redirect } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const POST: RequestHandler = async ({ request, locals: { supabase } }) => { - try { - // Create a new AuthService instance with the per-request Supabase client - // This ensures authentication state is isolated per user/request - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(supabase, errorHandler); - - // Attempt to sign in with the per-request auth service - const { data: authData, error } = await supabase.auth.signInWithOAuth({ - provider: 'google', - options: { - queryParams: { - access_type: 'offline', - prompt: 'consent' - } - } - }); - - if (error || !authData || (authData.url && authData.url.length === 0)) { - return json( - { - error: 'Google login failed. Please try again.' - }, - { status: 500 } - ); - } - - // Login successful - cookies are automatically handled by the per-request client - //console.log('Successfully authenticated user:', email); - - return json(authData.url, { status: 200 }); // Redirect to home or a specific page after successful login - } catch (error) { - console.error('Login error:', error); - return json( - { - error: 'An unexpected error occurred' - }, - { status: 500 } - ); - } -}; diff --git a/src/routes/api/auth/line/webhook-callback/+server.ts b/src/routes/api/auth/line/webhook-callback/+server.ts deleted file mode 100644 index 04d69421..00000000 --- a/src/routes/api/auth/line/webhook-callback/+server.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit'; - -/** - * LINE webhook callback endpoint (POST). - * Accepts JSON payloads. Responds with a simple acknowledgement. - * Extend this handler to verify the X-Line-Signature header and process events. - */ -export const POST: RequestHandler = async (event) => { - let payload: unknown = null; - try { - // Attempt to parse JSON body (LINE sends application/json) - if (event.request.headers.get('content-type')?.includes('application/json')) { - payload = await event.request.json(); - } else if (event.request.body) { - // Fallback: read text just in case - const text = await event.request.text(); - try { - payload = JSON.parse(text); - } catch { - payload = text; - } - } - } catch (err) { - return new Response( - JSON.stringify({ ok: false, error: 'Invalid JSON body', detail: (err as Error).message }), - { status: 400, headers: { 'content-type': 'application/json' } } - ); - } - - // TODO (optional): Verify signature - // const signature = event.request.headers.get('x-line-signature'); - // if (LINE_CHANNEL_SECRET && !verifyLineSignature(signature, LINE_CHANNEL_SECRET, rawBody)) { - // return new Response(JSON.stringify({ ok: false, error: 'Invalid signature' }), { status: 403, headers: { 'content-type': 'application/json' } }); - // } - - // Minimal echo response; adapt as needed for your application logic - return new Response(JSON.stringify({ ok: true, receivedAt: new Date().toISOString(), payload }), { - status: 200, - headers: { 'content-type': 'application/json' } - }); -}; - -// Provide a simple GET response so visiting the URL in a browser doesn't 404 (was causing confusion) -export const GET: RequestHandler = async () => { - return new Response( - JSON.stringify({ - ok: true, - message: 'LINE webhook endpoint is alive. Send POST requests with JSON body.' - }), - { status: 200, headers: { 'content-type': 'application/json' } } - ); -}; - -// (Optional future helper) Example signature verification placeholder -// function verifyLineSignature(signature: string | null, secret: string, body: string | ArrayBuffer) { -// if (!signature) return false; -// // Implement HMAC-SHA256 using secret over raw request body, then compare Base64 -// return true; -// } diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts deleted file mode 100644 index 0cc75808..00000000 --- a/src/routes/api/auth/login/+server.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const POST: RequestHandler = async ({ request, locals }) => { - try { - // Parse the request body - const { email, password } = await request.json(); - - // Create a new AuthService instance with the per-request Supabase client - // This ensures authentication state is isolated per user/request - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); - - // Attempt to sign in with the per-request auth service - const authData = await authService.signInWithPassword(email, password); - - if (!authData) { - return json( - { - error: 'Invalid email or password' - }, - { status: 401 } - ); - } - - // Login successful - cookies are automatically handled by the per-request client - //console.log('Successfully authenticated user:', email); - - return json({ - success: true, - user: { - id: authData.user.id, - email: authData.user.email, - name: authData.user.user_metadata?.name || email.split('@')[0], - access_token: authData.session.access_token, - refresh_token: authData.session.refresh_token, - expires_at: authData.session.expires_at - } - }); - } catch (error) { - console.error('Login error:', error); - return json( - { - error: 'An unexpected error occurred' - }, - { status: 500 } - ); - } -}; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts deleted file mode 100644 index e465ce26..00000000 --- a/src/routes/api/auth/logout/+server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { json, redirect } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const POST: RequestHandler = async ({ locals, cookies }) => { - try { - //console.log('Server logout endpoint called'); - - // Create a new AuthService instance with the per-request Supabase client - // This ensures authentication state is isolated per user/request - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); - - // Sign out the user using the auth service - await authService.signOut(); - - // Also sign out using the request-scoped Supabase client - if (locals.supabase) { - await locals.supabase.auth.signOut(); - } - - // kill the cookies - const authCookies = cookies.getAll(); - for (const cookie of authCookies) { - if (cookie.name.includes('supabase') || cookie.name.includes('auth')) { - cookies.delete(cookie.name, { path: '/' }); - } - } - - // is it dead yet? - const sessionAfter = await authService.getSession(); - //console.log('Session after logout:', sessionAfter?.user?.email || 'No session'); - - return json({ - success: true, - message: 'Logged out successfully' - }); - } catch (error) { - console.error('Logout error:', error); - return json( - { - error: 'An unexpected error occurred during logout' - }, - { status: 500 } - ); - } -}; diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts deleted file mode 100644 index a018ca93..00000000 --- a/src/routes/api/auth/register/+server.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -const passwordRequirementPatterns = { - lowercase: /[a-z]/, - uppercase: /[A-Z]/, - number: /\d/, - symbol: /[^A-Za-z0-9]/ -}; - -function meetsPasswordRequirements(password: string): boolean { - return ( - password.length >= 8 && - passwordRequirementPatterns.lowercase.test(password) && - passwordRequirementPatterns.uppercase.test(password) && - passwordRequirementPatterns.number.test(password) && - passwordRequirementPatterns.symbol.test(password) - ); -} - -export const POST: RequestHandler = async ({ request, locals }) => { - try { - // Parse the request body - const userData = await request.json(); - - // Validate required fields - if ( - !userData.email || - !userData.password || - !userData.firstName || - !userData.lastName || - !userData.company - ) { - return json( - { - success: false, - error: 'All fields are required' - }, - { status: 400 } - ); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(userData.email)) { - return json( - { - success: false, - error: 'Invalid email format' - }, - { status: 400 } - ); - } - - // Validate password requirements - if (!meetsPasswordRequirements(userData.password)) { - return json( - { - success: false, - error: - 'Password must be at least 8 characters and include a lowercase letter, uppercase letter, number, and symbol' - }, - { status: 400 } - ); - } - - // Get error handler - const errorHandler = new ErrorHandlingService(); - - // Create AuthService with the request's Supabase client - const authService = new AuthService(locals.supabase, errorHandler); - - // Register the user - const result = await authService.register({ - email: userData.email, - password: userData.password, - firstName: userData.firstName, - lastName: userData.lastName, - company: userData.company - }); - - if (!result.success) { - return json( - { - success: false, - error: result.error || 'Registration failed' - }, - { status: 400 } - ); - } - - return json({ - success: true, - message: 'Registration successful.', - emailConfirmationRequired: result.emailConfirmationRequired || false - }); - } catch (err) { - console.error('API Registration error:', err); - - return json( - { - success: false, - error: 'An unexpected error occurred during registration' - }, - { status: 500 } - ); - } -}; diff --git a/src/routes/api/auth/status/+server.ts b/src/routes/api/auth/status/+server.ts deleted file mode 100644 index ddcce57e..00000000 --- a/src/routes/api/auth/status/+server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - // Get error handler - const errorHandler = new ErrorHandlingService(); - - // Create AuthService with the request's Supabase client - const authService = new AuthService(locals.supabase, errorHandler); - - // Get the current session - const sessionData = await authService.getSession(); - - if (!sessionData) { - return json({ authenticated: false }); - } - - const { user } = sessionData; - - // Return user data - return json({ - authenticated: true, - user: { - id: user.id, - email: user.email, - name: user.user_metadata?.name || user.email?.split('@')[0] || 'User' - } - }); - } catch (error) { - console.error('Error checking authentication status:', error); - return json({ - authenticated: false, - reason: 'Error checking authentication status' - }); - } -}; diff --git a/src/routes/api/device-types/+server.ts b/src/routes/api/device-types/+server.ts new file mode 100644 index 00000000..88320a09 --- /dev/null +++ b/src/routes/api/device-types/+server.ts @@ -0,0 +1,24 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ fetch, locals }) => { + const authToken = locals.jwtString ?? null; + + if (!authToken) { + return json({ message: m.auth_not_authenticated() }, { status: 401 }); + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + return json(await api.getDeviceTypes()); + } catch (error) { + const status = error instanceof ApiServiceError ? error.status : 500; + return json({ message: m.devices_load_device_types_failed() }, { status }); + } +}; diff --git a/src/routes/api/devices/[devEui]/+server.ts b/src/routes/api/devices/[devEui]/+server.ts deleted file mode 100644 index 2f54e8b8..00000000 --- a/src/routes/api/devices/[devEui]/+server.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; -import { error, json, type RequestHandler } from '@sveltejs/kit'; - -export const GET: RequestHandler = async ({ - params, - url, - locals: { safeGetSession, supabase } -}) => { - const { devEui } = params; - const { session, user } = await safeGetSession(); - if (!session) { - console.error('Session is null on device data API for device:', devEui); - } - if (!devEui) { - console.error('Device EUI is missing in the request'); - throw error(400, 'Bad Request - Device EUI is required'); - } - - try { - // Create services directly - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - - const device = await deviceService.getDeviceWithTypeByEui(devEui); - - if (!device) { - throw error(404, 'Device not found'); - } - - // Return a proper Response object using json helper - return json(device); - } catch (err) { - console.error('Error fetching device data:', err); - - // If it's already a SvelteKit error, rethrow it - if (err && typeof err === 'object' && 'status' in err) { - throw err; - } - - // Otherwise throw a generic 500 error - throw error(500, 'Internal Server Error'); - } -}; - -export const DELETE: RequestHandler = async ({ - params, - url, - locals: { safeGetSession, supabase } -}) => { - const { devEui } = params; - const { session, user } = await safeGetSession(); - if (!session || !user) { - console.error('Session is null on device data API for device:', devEui); - throw error(401, 'Unauthorized - Authentication required'); - } - if (!devEui) { - console.error('Device EUI is missing in the request'); - throw error(400, 'Bad Request - Device EUI is required'); - } - try { - // Create services directly - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - - const device = await deviceService.getDeviceByEui(devEui); - if (!device) { - throw error(404, 'Device not found'); - } - // Check if the user is authorized to delete the device - if (device.user_id !== user.id) { - throw error(403, 'Forbidden - You do not have permission to delete this device'); - } - - // Delete the device - const deviceDeleted = await deviceService.deleteDevice(devEui); - - if (!deviceDeleted) { - throw error(500, 'Internal Server Error - Device could not be deleted'); - } - - // Return a proper Response object using json helper - return json({ message: 'Device deleted successfully' }); - } catch (err) { - console.error('Error deleting device data:', err); - - // If it's already a SvelteKit error, rethrow it - if (err && typeof err === 'object' && 'status' in err) { - throw err; - } - - // Otherwise throw a generic 500 error - throw error(500, 'Internal Server Error'); - } -}; diff --git a/src/routes/api/devices/[devEui]/csv/+server.ts b/src/routes/api/devices/[devEui]/csv/+server.ts deleted file mode 100644 index 81fb7a57..00000000 --- a/src/routes/api/devices/[devEui]/csv/+server.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { DateTime } from 'luxon'; - -export const GET: RequestHandler = async ({ - params, - url, - locals: { safeGetSession, supabase } -}) => { - const { devEui } = params; - const { session, user } = await safeGetSession(); - if (!session) { - console.error('Session is null on device data API for device:', devEui); - } - - try { - // Get query parameters for date range - const startDateParam = url.searchParams.get('start'); - const endDateParam = url.searchParams.get('end'); - const timezoneParam = url.searchParams.get('timezone') || 'UTC'; - - if (!startDateParam || !endDateParam) { - throw error(400, 'Start and end dates are required'); - } - - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - // Validate dates - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw error(400, 'Invalid date format'); - } - - // Include the full day in the results, but do this in the user's timezone - // Parse dates as local dates in the user's timezone, then set to start/end of day - const userTimezone = timezoneParam; - - // Create DateTime objects from the date strings in the user's timezone - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); - - // Convert back to JavaScript Date objects - startDate = startDt.toJSDate(); - endDate = endDt.toJSDate(); - - // Get services from the container - const deviceDataService = new DeviceDataService(supabase); - - // First, try to determine if this is an air data or soil data device based on latest data - let csvData = null; - - // Try to get data dynamically based on device type - - try { - csvData = await deviceDataService.getDeviceDataByDateRangeAsCSV( - devEui, - startDate, - endDate, - timezoneParam - ); - if (!csvData || csvData.length === 0) { - throw new Error('No data found for the specified date range'); - } - } catch (dataError) { - console.error(`Error fetching dynamic data for device ${devEui}:`, dataError); - // Will fall back to specific services below - } - - // Prepare CSV string - let csvString: string; - if (Array.isArray(csvData)) { - if (csvData.length === 0) { - throw error(404, 'No data found for the specified date range'); - } - // Determine headers, excluding 'id' and 'is_simulated' and any columns entirely empty - const origHeaders = Object.keys(csvData[0] as Record); - const headers = origHeaders - .filter((h) => h !== 'id' && h !== 'is_simulated') - .filter((h) => csvData.some((row) => (row as any)[h] != null && (row as any)[h] !== '')); - // Format rows with Excel-friendly dates - const rows = csvData.map((row) => { - return headers - .map((field) => { - let raw = (row as any)[field]; - let value: string = ''; - if (field === 'created_at' && raw) { - // Format for Excel: YYYY-MM-DD HH:mm:ss in requested timezone - const dt = DateTime.fromJSDate(new Date(raw)).setZone(userTimezone); - value = dt.toFormat('yyyy-LL-dd HH:mm:ss'); - } else if (raw != null) { - value = String(raw); - } - // Escape and quote - const escaped = value.replace(/"/g, '""'); - return `"${escaped}"`; - }) - .join(','); - }); - csvString = [headers.join(','), ...rows].join('\n'); - } else if (typeof csvData === 'string') { - // Parse raw CSV string, exclude empty columns, filter out 'id' & 'is_simulated', format 'created_at' - const rawCsv: string = csvData; - const lines: string[] = rawCsv.trim().split('\n'); - const originalHeaders: string[] = lines[0].split(','); - // Parse data lines into fields matrix - const dataLines: string[] = lines.slice(1).filter((l: string) => l.trim()); - const regex = /(\".*?\"|[^,]+)(?=,|$)/g; - const fieldMatrix: string[][] = dataLines.map((line: string) => { - const matches = Array.from(line.matchAll(regex)) as RegExpMatchArray[]; - return matches.map((m: RegExpMatchArray) => m[1]); - }); - // Determine which columns to keep: exclude 'id', 'is_simulated' and columns with all empty - const keepIndices: number[] = originalHeaders - .map((h: string, idx: number) => ({ h, idx })) - .filter(({ h }) => h !== 'id' && h !== 'is_simulated') - .filter(({ idx }) => - fieldMatrix.some((fields) => { - const val = fields[idx]?.replace(/^"|"$/g, ''); - return val !== null && val !== undefined && val !== ''; - }) - ) - .map(({ idx }) => idx); - const newHeaders: string[] = keepIndices.map((i: number) => originalHeaders[i]); - // Build new rows with formatting - const newRows: string[] = fieldMatrix.map((fields: string[]): string => { - return keepIndices - .map((idx: number): string => { - const raw = fields[idx] ?? ''; - let value: string; - const header = originalHeaders[idx]; - if (header === 'created_at') { - const unq = raw.replace(/^"|"$/g, ''); - const sqlDate = unq.split('+')[0]; - const dt = DateTime.fromSQL(sqlDate, { zone: 'utc' }); - value = dt.toFormat('yyyy-LL-dd HH:mm:ss'); - } else { - value = raw.replace(/^"|"$/g, ''); - } - // Escape and quote final value - const escaped = value.replace(/"/g, '""'); - return `"${escaped}"`; - }) - .join(','); - }); - csvString = [newHeaders.join(','), ...newRows].join('\n'); - } else { - throw error(500, 'Invalid CSV data format'); - } - // Return CSV as a proper Response with headers - return new Response(csvString, { - status: 200, - headers: { - 'Content-Type': 'text/csv', - 'Content-Disposition': `attachment; filename="${devEui}.csv"` - } - }); - } catch (err) { - console.error(`Error in device data API for ${devEui}:`, err); - - if (err instanceof Error) { - throw error(500, err.message); - } else { - throw error(500, 'Unknown error occurred'); - } - } -}; diff --git a/src/routes/api/devices/[devEui]/data-jwt/+server.ts b/src/routes/api/devices/[devEui]/data-jwt/+server.ts deleted file mode 100644 index 7f65e5fe..00000000 --- a/src/routes/api/devices/[devEui]/data-jwt/+server.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { DateTime } from 'luxon'; -import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; -import { createClient } from '@supabase/supabase-js'; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; - -/** - * JWT-specific endpoint for device data retrieval - * Optimized for external API access (Node-RED, automation tools, etc.) - * - * Usage: - * GET /api/devices/{devEui}/data-jwt?start=2025-05-01&end=2025-06-06 - * Headers: Authorization: Bearer {jwt-token} - * - * Uses JWT-authenticated Supabase client to make RLS work properly with JWT tokens. - */ -export const GET: RequestHandler = async ({ params, url, request, locals: { supabase } }) => { - const { devEui } = params; - - // Extract JWT token from Authorization header - const authHeader = request.headers.get('authorization') || request.headers.get('Authorization'); - const jwt = authHeader?.replace(/^Bearer\s+/i, '').trim(); - - if (!jwt) { - console.error('No JWT token provided for device data API'); - throw error(401, 'Authorization header with Bearer token is required'); - } - - // Create a new Supabase client with JWT context for RLS to work properly - // This is the key fix - RLS needs the JWT in the client headers - const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { - headers: { Authorization: `Bearer ${jwt}` } - }, - auth: { persistSession: false } - }); - - // Validate JWT token and get user - let user = null; - try { - //console.log('Validating JWT token for device data access'); - const { data, error: authError } = await jwtSupabase.auth.getUser(jwt); - - if (authError || !data?.user) { - console.error('Invalid JWT token:', authError?.message); - throw error(401, 'Invalid or expired JWT token'); - } - - user = data.user; - //console.log( - // `JWT-authenticated device data request from user: ${user.email} for device: ${devEui}` - // ); - //console.log('User ID from JWT:', user.id); // Confirm JWT identity - - // Check if user has permission to access this device using JWT client - const { data: deviceOwnership, error: permissionError } = await jwtSupabase - .from('cw_device_owners') - .select('permission_level') - .eq('dev_eui', devEui) - .eq('user_id', user.id) - .maybeSingle(); - - if (permissionError) { - console.error('Error checking device ownership:', permissionError); - throw error(500, 'Failed to verify device permissions'); - } - - if (!deviceOwnership) { - console.warn(`User ${user.email} does not have permission to access device ${devEui}`); - throw error(403, 'You do not have permission to access this device'); - } - - //console.log( - // `User ${user.email} has permission level ${deviceOwnership.permission_level} for device ${devEui}` - // ); - } catch (authErr) { - console.error('JWT validation error:', authErr); - throw error(401, 'Failed to validate JWT token'); - } - - try { - // Get query parameters for date range - const startDateParam = url.searchParams.get('start'); - const endDateParam = url.searchParams.get('end'); - const timezoneParam = url.searchParams.get('timezone') || 'UTC'; - - if (!startDateParam || !endDateParam) { - throw error( - 400, - 'Missing required parameters: Both start and end dates are required. Example: ?start=2025-05-01&end=2025-06-06' - ); - } - - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - // Validate dates - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw error( - 400, - 'Invalid date format: Dates must be in ISO format (YYYY-MM-DD). Example: ?start=2025-05-01&end=2025-06-06' - ); - } - - // Include the full day in the results, but do this in the user's timezone - const userTimezone = timezoneParam; - - // Create DateTime objects from the date strings in the user's timezone - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); - - // Convert back to JavaScript Date objects - startDate = startDt.toJSDate(); - endDate = endDt.toJSDate(); - - //console.log( - // `[JWT API] Fetching data for device ${devEui} from ${startDate.toISOString()} to ${endDate.toISOString()}` - // ); - - let historicalData: DeviceDataRecord[] = []; - - try { - // Use the JWT-authenticated Supabase client for all queries - // This ensures RLS sees the correct user context - //console.log('Using JWT-authenticated Supabase client for data queries...'); - - // First attempt: Use DeviceDataService with JWT-authenticated client - const deviceDataService = new DeviceDataService(jwtSupabase); - historicalData = await deviceDataService.getDeviceDataByDateRange( - devEui, - startDate, - endDate, - timezoneParam - ); - - if (historicalData && historicalData.length > 0) { - //console.log( - // `[JWT API] DeviceDataService succeeded: Found ${historicalData.length} records` - // ); - } else { - //console.log('DeviceDataService returned no data, trying direct table queries...'); - - // Second attempt: Direct table queries using JWT client - // Try cw_air_data first (most common) - const { data: airData, error: airError } = await jwtSupabase - .from('cw_air_data') - .select('*') - .eq('dev_eui', devEui) - .gte('created_at', startDate.toISOString()) - .lte('created_at', endDate.toISOString()) - .order('created_at', { ascending: false }); - - if (airError) { - console.error('Error querying cw_air_data:', airError); - } - - if (airData && airData.length > 0) { - historicalData = airData as DeviceDataRecord[]; - //console.log(`[JWT API] Found ${airData.length} air data records`); - } else { - // Try cw_soil_data if no air data found - const { data: soilData, error: soilError } = await jwtSupabase - .from('cw_soil_data') - .select('*') - .eq('dev_eui', devEui) - .gte('created_at', startDate.toISOString()) - .lte('created_at', endDate.toISOString()) - .order('created_at', { ascending: false }); - - if (soilError) { - console.error('Error querying cw_soil_data:', soilError); - } - - if (soilData && soilData.length > 0) { - historicalData = soilData as DeviceDataRecord[]; - //console.log(`[JWT API] Found ${soilData.length} soil data records`); - } else { - //console.log( - // `[JWT API] No data found in either air or soil tables for device ${devEui}` - // ); - } - } - } - - if (!historicalData || historicalData.length === 0) { - console.warn( - `[JWT API] No historical data found for device ${devEui} in the specified date range` - ); - return json({ - success: false, - message: 'No historical data found for the specified date range.', - data: [], - metadata: { - device: devEui, - user: user.email, - dateRange: { - start: startDate.toISOString(), - end: endDate.toISOString() - }, - requestedAt: new Date().toISOString() - } - }); - } - - //console.log(`[JWT API] Found ${historicalData.length} records for device ${devEui}`); - - // Return structured response for API consumption - return json({ - success: true, - data: historicalData, - metadata: { - device: devEui, - user: user.email, - count: historicalData.length, - dateRange: { - start: startDate.toISOString(), - end: endDate.toISOString() - }, - requestedAt: new Date().toISOString() - } - }); - } catch (dataError) { - console.error(`[JWT API] Error fetching historical data for device ${devEui}:`, dataError); - - const errorMessage = - dataError instanceof Error ? dataError.message : 'Unknown error occurred'; - - // Return detailed error information for API consumers - if (errorMessage.includes('Date range too large')) { - throw error(400, 'Date range too large: Please limit the date range to 3 months or less'); - } - - if (errorMessage.includes('timeout')) { - throw error(408, 'Request timeout: Please try with a smaller date range or use pagination'); - } - - throw error(500, `Data retrieval failed for device ${devEui}: ${errorMessage}`); - } - } catch (err) { - console.error(`[JWT API] Error in device data API for ${devEui}:`, err); - - // If it's already a SvelteKit error, re-throw it - if (err && typeof err === 'object' && 'status' in err) { - throw err; - } - - if (err instanceof Error) { - throw error(500, `Internal server error for device ${devEui}: ${err.message}`); - } else { - throw error(500, `Unknown error occurred for device ${devEui}`); - } - } -}; diff --git a/src/routes/api/devices/[devEui]/data/+server.ts b/src/routes/api/devices/[devEui]/data/+server.ts deleted file mode 100644 index cfc73afb..00000000 --- a/src/routes/api/devices/[devEui]/data/+server.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { error, json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; -import { DateTime } from 'luxon'; - -export const GET: RequestHandler = async ({ - params, - url, - locals: { safeGetSession, supabase } -}) => { - const { devEui } = params; - const { session, user } = await safeGetSession(); - if (!session) { - console.error('Session is null on device data API for device:', devEui); - } - - try { - // Get query parameters for date range - const startDateParam = url.searchParams.get('start'); - const endDateParam = url.searchParams.get('end'); - const timezoneParam = url.searchParams.get('timezone') || 'UTC'; - - if (!startDateParam || !endDateParam) { - throw error(400, 'Start and end dates are required'); - } - - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - - // Validate dates - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw error(400, 'Invalid date format'); - } - - // Include the full day in the results, but do this in the user's timezone - const userTimezone = timezoneParam; - - // Create DateTime objects from the date strings in the user's timezone - const startDt = DateTime.fromISO(startDateParam + 'T00:00:00', { zone: userTimezone }).startOf( - 'day' - ); - const endDt = DateTime.fromISO(endDateParam + 'T23:59:59', { zone: userTimezone }).endOf('day'); - - // Convert back to JavaScript Date objects - startDate = startDt.toJSDate(); - endDate = endDt.toJSDate(); - - // Get services from the container - const deviceDataService = new DeviceDataService(supabase); - - // First, try to determine if this is an air data or soil data device based on latest data - let latestData = null; - - // Try to get data dynamically based on device type - - try { - latestData = await deviceDataService.getLatestDeviceData(devEui); - if (!latestData || latestData.length === 0) { - throw new Error('No data found for the specified date range'); - } - } catch (dataError) { - console.error(`Error fetching dynamic data for device ${devEui}:`, dataError); - // Will fall back to specific services below - } - - let historicalData: DeviceDataRecord[] = []; - try { - historicalData = await deviceDataService.getDeviceDataByDateRange( - devEui, - startDate, - endDate, - timezoneParam - ); - if (!historicalData || historicalData.length === 0) { - throw new Error('No historical data found for the specified date range'); - } - } catch (err) { - console.error(`Error fetching historical data for device ${devEui}:`, err); - historicalData = []; - } - - // Return the JSON response directly - return json(historicalData); - } catch (err) { - console.error(`Error in device data API for ${devEui}:`, err); - - if (err instanceof Error) { - throw error(500, err.message); - } else { - throw error(500, 'Unknown error occurred'); - } - } -}; diff --git a/src/routes/api/devices/[devEui]/downlink/+server.ts b/src/routes/api/devices/[devEui]/downlink/+server.ts deleted file mode 100644 index 3b64b3c9..00000000 --- a/src/routes/api/devices/[devEui]/downlink/+server.ts +++ /dev/null @@ -1,148 +0,0 @@ -// src/routes/api/v1/devices/[devEui]/downlink/+server.ts -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DRAGINO_LT22222L_PAYLOADS } from '$lib/lorawan/dragino'; - -// SvelteKit v5: use $env/static/private on the server -import { PRIVATE_TTI_DRAGINO_RELAY_KEY } from '$env/static/private'; - -// --- Config --- -// IS (metadata) on EU1, AS (downlink) on AU1 unless you override via env. -const TTI_IS_BASE = 'https://cropwatch.eu1.cloud.thethings.industries'; -const TTI_AS_BASE = 'https://cropwatch.au1.cloud.thethings.industries'; -const TTI_KEY = PRIVATE_TTI_DRAGINO_RELAY_KEY; - -// Minimal fields to fetch when listing/searching devices -const FIELD_MASK = 'ids,ids.dev_eui,name'; - -// --- Helpers --- -function normDevEui(eui: string): string { - return eui.replace(/[:\-\s]/g, '').toUpperCase(); -} - -async function ttiFetch(url: string, init?: RequestInit): Promise { - if (!TTI_KEY) throw error(500, 'PRIVATE_TTI_DRAGINO_RELAY_KEY not configured'); - const headers = { - Authorization: `Bearer ${TTI_KEY}`, - 'Content-Type': 'application/json', - 'User-Agent': 'cropwatch-api/1.0', - ...(init?.headers ?? {}) - }; - return fetch(url, { ...init, headers }); -} - -type EndDeviceLite = { ids?: { device_id?: string; dev_eui?: string }; name?: string }; -type ListResp = { end_devices?: EndDeviceLite[] }; - -// Search (preferred). -async function resolveDeviceIdViaSearch(appId: string, devEui: string): Promise { - const url = new URL( - `${TTI_IS_BASE}/api/v3/search/applications/${encodeURIComponent(appId)}/devices` - ); - url.searchParams.set('page', '1'); - url.searchParams.set('limit', '200'); - url.searchParams.set('query', ''); - url.searchParams.set('order', '-created_at'); - url.searchParams.set('field_mask', FIELD_MASK); - - const r = await ttiFetch(url.toString()); - if (r.status === 404) return null; // search not exposed on some tenants - if (!r.ok) throw error(502, `TTI Search failed (${r.status}): ${await r.text()}`); - - const data = (await r.json()) as ListResp; - const match = (data.end_devices ?? []).find((d) => d?.ids?.dev_eui?.toUpperCase() === devEui); - return match?.ids?.device_id ?? null; -} - -// Fallback: List + client-side match (always available on EU1). -async function resolveDeviceIdViaList(appId: string, devEui: string): Promise { - const pageSize = 500; - for (let page = 1; page <= 200; page++) { - const url = new URL(`${TTI_IS_BASE}/api/v3/applications/${encodeURIComponent(appId)}/devices`); - url.searchParams.set('field_mask', FIELD_MASK); - url.searchParams.set('limit', String(pageSize)); - url.searchParams.set('page', String(page)); - - const r = await ttiFetch(url.toString()); - if (!r.ok) throw error(502, `TTI List failed (${r.status}): ${await r.text()}`); - - const data = (await r.json()) as ListResp; - const match = (data.end_devices ?? []).find((d) => d?.ids?.dev_eui?.toUpperCase() === devEui); - if (match?.ids?.device_id) return match.ids.device_id; - - if ((data.end_devices?.length ?? 0) < pageSize) break; - } - return null; -} - -async function resolveTtiDeviceId(appId: string, devEuiRaw: string): Promise { - const eui = normDevEui(devEuiRaw); - const fromSearch = await resolveDeviceIdViaSearch(appId, eui); - if (fromSearch) return fromSearch; - - const fromList = await resolveDeviceIdViaList(appId, eui); - if (fromList) return fromList; - - throw error(404, 'TTI device not found for DevEUI in this application'); -} - -// --- Route --- -export const POST: RequestHandler = async ({ - params, - request, - locals: { supabase, safeGetSession } -}) => { - const { session, user } = await safeGetSession(); - if (!session || !user) throw error(401, 'Authentication required'); - - const devEuiParam = params.devEui; - if (!devEuiParam) throw error(400, 'Device EUI is required'); - - const errorHandler = new ErrorHandlingService(); - const repo = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(repo); - - const device = await deviceService.getDeviceWithTypeByEui(devEuiParam); - if (!device) throw error(404, 'Device not found'); - - const owner = await repo.findDeviceOwner(devEuiParam, user.id); - if (!owner) throw error(403, 'Forbidden'); - - const body = await request.json().catch(() => ({}) as any); - const payloadName = body.payloadName as keyof typeof DRAGINO_LT22222L_PAYLOADS | undefined; - const frm_payload = body.frm_payload as string | undefined; - const base64Payload = payloadName ? DRAGINO_LT22222L_PAYLOADS[payloadName] : frm_payload; - if (!base64Payload) throw error(400, 'No payload specified'); - - const appId = device.cw_device_type?.TTI_application_id; - if (!appId) throw error(500, 'Device type missing TTI application id'); - - const ttiDeviceId = device.tti_name || (await resolveTtiDeviceId(appId, devEuiParam)); - - const downlink = { - frm_payload: base64Payload, - f_port: Number.isInteger(body.f_port) ? body.f_port : 2, - priority: (body.priority as 'LOW' | 'NORMAL' | 'HIGH' | 'HIGHEST') ?? 'HIGH', - confirmed: typeof body.confirmed === 'boolean' ? body.confirmed : true - }; - - const url = `${TTI_AS_BASE}/api/v3/as/applications/${encodeURIComponent( - appId - )}/devices/${encodeURIComponent(ttiDeviceId)}/down/replace`; - - const resp = await ttiFetch(url, { - method: 'POST', - body: JSON.stringify({ downlinks: [downlink] }) - }); - - if (!resp.ok) { - const text = await resp.text(); - console.error('TTI downlink error', text); - throw error(502, `Failed to send downlink (${resp.status})`); - } - - return json({ success: true, appId, device_id: ttiDeviceId }); -}; diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts deleted file mode 100644 index c135506d..00000000 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ /dev/null @@ -1,588 +0,0 @@ -export const config = { runtime: 'nodejs20.x' }; - -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { i18n } from '$lib/i18n/index.svelte'; -import type { DeviceDataRecord } from '$lib/models/DeviceDataRecord'; -import type { ReportAlertPoint } from '$lib/models/Report'; -import type { TableCell, TableRow } from '$lib/pdf'; -import { - createPDFDataTable, - sampleDataRowsForTable, - sampleDataRowsByInterval -} from '$lib/pdf/pdfDataTable'; -import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; -import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; -import { checkMatch, getValue } from '$lib/pdf/utils'; -import { parseDeviceInstant } from '$lib/pdf/parseDeviceInstant'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { DeviceService } from '$lib/services/DeviceService'; -import { LocationService } from '$lib/services/LocationService'; -import { formatNumber, getColorNameByKey, getNumericKeys } from '$lib/utilities/stats'; -import { error as httpError, json } from '@sveltejs/kit'; -import fs from 'fs'; -import { DateTime } from 'luxon'; -import path from 'path'; -import PDFDocument from 'pdfkit'; -import { _ } from 'svelte-i18n'; -import { get } from 'svelte/store'; -import type { RequestHandler } from './$types'; -import { drawSummaryPanel } from './drawSummaryPanel'; -import { drawRightAlertPanel } from './drawRightAlertPanel'; - -function normalizeDeviceDataTimestamps(records: DeviceDataRecord[], timezone: string) { - if (!records || !records.length) return records ?? []; - return records.map((record) => { - const next = { ...record } as Record; - const stampKeys: Array = ['created_at', 'traffic_hour']; - for (const key of stampKeys) { - const raw = next[key]; - if (!raw) continue; - const parsed = parseDeviceInstant(raw as string | Date, timezone); - if (parsed.isValid) { - next[key] = parsed.toISO(); - } - } - return next as DeviceDataRecord; - }); -} - -/** - * JWT-authenticated PDF generation endpoint for device data reports - * Designed for server-to-server calls (Node-RED, automation tools, etc.) - * - * GET /api/devices/{devEui}/pdf?start=2025-05-01&end=2025-06-06&timezone=Asia/Tokyo&locale=ja - * Headers: Authorization: Bearer {jwt-token} - */ -export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) => { - const { devEui } = params; - - try { - if (!supabase) { - return json({ error: 'Unauthorized access' }, { status: 401 }); - } - const { data: userData, error: userError } = await supabase.auth.getUser(); - if (userError || !userData) { - return json( - { error: `Unauthorized access - ${userError?.message}` }, - { status: userError?.status ?? 401 } - ); - } - const { user } = userData; - if (!user) throw httpError(404, 'Device not found'); - - const { data: userProfile } = await supabase - .from('profiles') - .select(`full_name, employer`) - .eq('id', user.id) - .single(); - - const { - start: startDateParam, - end: endDateParam, - dataKeys: dataKeysParam = '', - alertPoints: _alertPointsParam, // (kept for parity; not used — we source from DB) - locale: localeParam = 'ja', - timezone: timezoneParam = 'Asia/Tokyo' - } = Object.fromEntries(url.searchParams); - - await i18n.initialize({ initialLocale: localeParam }); - const $_ = get(_); - - if (!startDateParam || !endDateParam) { - return json( - { - error: 'Missing required parameters: Both start and end dates are required', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); - } - - // Parse and normalize requested window in the user’s timezone, then convert to UTC for DB - let startDate = new Date(startDateParam); - let endDate = new Date(endDateParam); - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - return json( - { - error: 'Invalid date format: Dates must be YYYY-MM-DD (or ISO8601).', - example: '?start=2025-05-01&end=2025-06-06' - }, - { status: 400 } - ); - } - const userStart = DateTime.fromJSDate(startDate).setZone(timezoneParam).startOf('day'); - const userEnd = DateTime.fromJSDate(endDate).setZone(timezoneParam).endOf('day'); - const startLabel = userStart.toFormat('yyyy-MM-dd HH:mm'); - const endLabel = userEnd.toFormat('yyyy-MM-dd HH:mm'); - const startDateUtc = userStart.toUTC().toJSDate(); - const endDateUtc = userEnd.toUTC().toJSDate(); - const startDateLocal = new Date( - userStart.year, - userStart.month - 1, - userStart.day, - userStart.hour, - userStart.minute, - userStart.second - ); - const endDateLocal = new Date( - userEnd.year, - userEnd.month - 1, - userEnd.day, - userEnd.hour, - userEnd.minute, - userEnd.second - ); - - const selectedKeys = dataKeysParam - .split(',') - .map((k) => k.trim()) - .filter(Boolean); - - // Pull alert points from DB (same behavior as older code) - const { data: reportParams, error: reportFetchErr } = await supabase - .from('reports') - .select('report_id, report_alert_points(*)') - .eq('dev_eui', devEui) - .limit(1) // NOTE: maintained to match existing behavior - .single(); - - if (reportFetchErr) { - return json( - { error: `Failed to fetch report parameters - ${reportFetchErr.message}` }, - { status: 404 } - ); - } - - let requestedAlertPoints: ReportAlertPoint[] = []; - for (const point of reportParams?.report_alert_points || []) { - if (!point.data_point_key) { - console.warn(`Alert point with ID ${point.id} has no data_point_key, skipping`); - continue; - } - requestedAlertPoints.push({ - created_at: point.created_at, - data_point_key: point.data_point_key, - hex_color: point.hex_color || '#ffffff', - id: point.id, - max: point.max ?? null, - min: point.min ?? null, - name: point.name || point.data_point_key, - operator: point.operator || 'eq', - report_id: point.report_id, - user_id: point.user_id, - value: point.value ?? 0 - }); - } - - // Determine "report" mode to use getDeviceDataForReport - const isReport = !requestedAlertPoints.length; - - // Repos/Services - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - const device = await deviceService.getDeviceWithTypeByEui(devEui); - if (!device) throw httpError(404, 'Device not found'); - - const locationRepo = new LocationRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - const location = await locationService.getLocationById(device.location_id!); - if (!location) throw httpError(404, 'Location not found'); - - const deviceDataService = new DeviceDataService(supabase); - - // Fetch data using the timezone-aware path - const deviceDataResponse = isReport - ? await deviceDataService.getDeviceDataForReport({ - devEui, - startDate: startDateUtc, - endDate: endDateUtc, - timezone: timezoneParam, - columns: requestedAlertPoints.map((p) => p.data_point_key as string), - ops: requestedAlertPoints.map((p) => p.operator as string), - mins: requestedAlertPoints.map((p) => (p.min ?? p.value) as number), - maxs: requestedAlertPoints.map((p) => p.max ?? null), - intervalMinutes: 30 - }) - : await deviceDataService.getDeviceDataByDateRange( - devEui, - startDateLocal, - endDateLocal, - timezoneParam - ); - - let deviceData: DeviceDataRecord[] = []; - let alertPoints: ReportAlertPoint[] = requestedAlertPoints; - - if (deviceDataResponse?.length) { - console.log('PDF report raw timestamp sample', { - devEui, - timezone: timezoneParam, - firstRaw: deviceDataResponse[0]?.created_at, - lastRaw: deviceDataResponse[deviceDataResponse.length - 1]?.created_at, - rawKeys: Object.keys(deviceDataResponse[0] ?? {}) - }); - deviceData = normalizeDeviceDataTimestamps(deviceDataResponse, timezoneParam); - console.log('PDF report normalized timestamp sample', { - devEui, - timezone: timezoneParam, - firstNormalized: deviceData[0]?.created_at, - lastNormalized: deviceData[deviceData.length - 1]?.created_at - }); - const windowStart = userStart.toMillis(); - const windowEnd = userEnd.toMillis(); - const beforeFilterCount = deviceData.length; - deviceData = deviceData.filter((record) => { - const dt = parseDeviceInstant(record.created_at as string, timezoneParam); - const ms = dt.toMillis(); - return ms >= windowStart && ms <= windowEnd; - }); - console.log('PDF report filtered dataset', { - devEui, - beforeFilterCount, - afterFilterCount: deviceData.length, - firstAfterFilter: deviceData[0]?.created_at, - lastAfterFilter: deviceData[deviceData.length - 1]?.created_at - }); - if (!alertPoints.length) { - alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); - } - } else { - return json( - { - error: `No data found for device ${devEui} in the specified range`, - device: devEui, - dateRange: { start: startDateParam, end: endDateParam } - }, - { status: 404 } - ); - } - - // Sort by created_at using timezone-correct parser - deviceData.sort((a, b) => { - const aMs = parseDeviceInstant(a.created_at as string, timezoneParam).toMillis(); - const bMs = parseDeviceInstant(b.created_at as string, timezoneParam).toMillis(); - return aMs - bMs; - }); - - // Build PDF — keep the **old layout** with header, signature boxes, charts, full table, footer - const doc = new PDFDocument({ - size: 'A4', - margin: 40, - info: { - Title: `Device ${devEui} Report`, - Author: `CropWatch API`, - Subject: `Data report for device ${devEui} from ${startDateParam} to ${endDateParam}`, - Creator: 'CropWatch API' - }, - bufferPages: true - }); - - // Load JP font if available - const possibleFontPaths = [ - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf'), - 'static/fonts/NotoSansJP-Regular.ttf' - ]; - for (const fp of possibleFontPaths) { - try { - if (fs.existsSync(fp)) { - doc.registerFont('NotoSansJP', fp); - doc.font('NotoSansJP'); - break; - } - } catch { - // ignore - } - } - - const { - top: marginTop, - right: marginRight, - bottom: marginBottom, - left: marginLeft - } = doc.page.margins; - const contentWidth = doc.page.width - marginLeft - marginRight; - - // Title - const totalRangeDays = Math.abs(userEnd.diff(userStart, 'days').days); - const isWeekly = Math.abs(totalRangeDays - 7) < 0.1; - const chartIntervalMinutes = totalRangeDays > 7 ? 60 : totalRangeDays > 3 ? 30 : 0; - const tableDisplayIntervalMinutes = 30; - // const titleText = isWeekly ? $_('device_report_weekly') : $_('device_report_monthly'); - // doc.fontSize(16).text(`${titleText} ${$_('device_report')}`); - doc.fontSize(16).text(`${$_('device_report')}`); - - // Signature boxes (old layout) - doc.fontSize(10).strokeColor('#ccc'); - doc.rect(400, marginTop, 50, 60).stroke(); - doc.rect(450, marginTop, 50, 60).stroke(); - doc.rect(500, marginTop, 50, 60).stroke(); - doc.text($_('approved'), 405, 45); - doc.text($_('verified'), 455, 45); - doc.text($_('created'), 505, 45); - - doc.x = marginLeft; - doc.y = 70; - - const metaTextOptions = { width: 320 }; - - // Metadata block - // doc - // .fontSize(8) - // .text( - // `${$_('generated_at')}: ${DateTime.now().setZone(timezoneParam).toFormat('yyyy-MM-dd HH:mm:ss')}`, - // metaTextOptions - // ) - // .text( - // `${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, - // metaTextOptions - // ); - // if (userProfile?.employer) { - // doc.text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, metaTextOptions); - // } - doc - .moveDown(0.5) - .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, metaTextOptions) - .text( - `${$_('Device Type')}: ${device.cw_device_type?.name || $_('Unknown')}`, - metaTextOptions - ) - .text(`${$_('Device Name')}: ${device.name || $_('Unknown')}`, metaTextOptions) - .text(`${$_('EUI')}: ${devEui}`, metaTextOptions); - - // Right side quick facts - // doc.moveUp(2); - // doc.x = 400; - // doc - // .text(`${$_('date_range')}: ${startLabel} - ${endLabel} (${timezoneParam})`) - // .text(`${$_('sampling_size')}: ${deviceData.length}`); - - doc.x = marginLeft; - doc.moveDown(); - - // Keys & columns - const numericKeys = getNumericKeys(deviceData); - const validKeys = - isReport || !selectedKeys.length - ? numericKeys - : numericKeys.filter((k) => selectedKeys.includes(k)); - - // Restrict table/graph columns to alert-linked columns if available - const alertKeySet = new Set(alertPoints.map((p) => p.data_point_key)); - const alertKeys = validKeys.filter((k) => alertKeySet.has(k)); - const tableKeys = alertKeys.length ? alertKeys : validKeys; - - const keyColumns: TableCell[] = validKeys.map((key) => ({ - label: $_(key), - value: '', - width: 30, - color: getColorNameByKey(key) - })); - - const dataHeader: TableRow = { - header: { label: $_('datetime'), value: '', width: 40 }, - cells: [...keyColumns, { label: $_('comment'), width: 40 }] - }; - - // Build rows using timezone-aware labels and carry ISO+offset in value - const getDataRows = (keys: string[] = validKeys): TableRow[] => - deviceData.map((data, index) => { - const date = parseDeviceInstant(data.created_at as string, timezoneParam); - const previousDate = - index > 0 - ? parseDeviceInstant(deviceData[index - 1].created_at as string, timezoneParam) - : null; - - const isoWithZone = date.toISO(); // carries offset - const labelLocal = date.toFormat('M/d H:mm'); - const shortLocal = date.toFormat('H:mm'); - - return { - header: { - value: isoWithZone, - label: labelLocal, - shortLabel: - previousDate?.toFormat('M/d') === date.toFormat('M/d') ? shortLocal : labelLocal - }, - cells: keys.map((key) => { - const rawValue = (data as Record)[key]; - const value = - typeof rawValue === 'number' && !isNaN(rawValue as number) ? (rawValue as number) : 0; - - return { - value, - label: - typeof rawValue === 'number' && !isNaN(rawValue as number) - ? formatNumber({ key, value, adjustFractionDigits: true }) - : '', - bgColor: - alertPoints.find( - (point) => point.data_point_key === key && checkMatch(value, point) - )?.hex_color ?? '#ffffff' - }; - }) - } as TableRow; - }); - - // Prepare rows/tables - const dataRows = getDataRows(); - const tableKeyColumns: TableCell[] = tableKeys.map((key) => ({ - label: $_(key), - value: '', - width: 30, - color: getColorNameByKey(key) - })); - const dataHeaderTable: TableRow = { - header: { label: $_('datetime'), value: '', width: 40 }, - cells: [...tableKeyColumns, { label: $_('comment'), width: 40 }] - }; - const dataRowsTable = getDataRows(tableKeys); - const sampledTableRows = sampleDataRowsByInterval(dataRowsTable, tableDisplayIntervalMinutes); - const tableConfig = { timezone: timezoneParam, takeEvery: 1 }; - - // LEFT summary - const primaryKey = tableKeys[0] ?? validKeys[0] ?? 'temperature_c'; - const { firstRowY, bottomY: summaryBottomY } = drawSummaryPanel(doc, { - dataRows, - validKeys, - primaryKey, - caption: $_('summary'), - samplingLabel: $_('sampling_size'), - dateRangeLabel: $_('date_range'), - maxLabel: $_('max'), - minLabel: $_('min'), - avgLabel: $_('avg'), - stddevLabel: $_('stddev'), - displayStartLabel: startLabel, - displayEndLabel: endLabel, - locale: localeParam, - tableWidthPercent: 0.5, - lineColor: '#000' - }); - - // RIGHT alerts — align exactly with “サンプリング数” - const panelX = 400; - const panelW = 180; - const rightBottomY = drawRightAlertPanel({ - doc, - x: panelX, - y: firstRowY, // ← precise alignment with the first row - width: panelW, - locale: localeParam, - startLabel: startLabel, - endLabel: endLabel, - timezone: timezoneParam, - samplingLabel: $_('sampling_size'), - sampleCount: deviceData.length, - alertPoints, - validKeys, - dataRows - }); - - // Normalize flow so charts start below both blocks - doc.x = doc.page.margins.left; - doc.y = Math.max(summaryBottomY, rightBottomY) + 12; - - // Charts (keep old layout behavior) - const chartWidth = contentWidth; - const chartHeight = contentWidth * 0.4; - const chartKeys = tableKeys; - for (const key of chartKeys) { - if (doc.y > doc.page.height - marginBottom - chartHeight + 20) { - doc.addPage(); - } else { - doc.moveDown(2); - } - const chartRows = getDataRows([key]); - const sampledChartRows = sampleDataRowsByInterval(chartRows, chartIntervalMinutes); - createPDFLineChartImage({ - doc, - dataHeader: { - header: { label: $_('datetime'), value: '', width: 60 }, - cells: [{ label: $_(key), value: '', width: 40, color: getColorNameByKey(key) }] - }, - dataRows: sampledChartRows, - alertPoints, - config: { title: $_(key), width: chartWidth, height: chartHeight, timezone: timezoneParam } - }); - } - - // Page break before full table - if (chartKeys.length > 0) doc.addPage(); - else doc.moveDown(2); - - // Full data table (restricted to alert-linked columns if any) - createPDFDataTable({ - doc, - dataHeader: dataHeaderTable, - dataRows: sampledTableRows, - config: tableConfig - }); - - // Footer - const footerText = [ - location.name, - device.name, - devEui, - `${startLabel} - ${endLabel} (${timezoneParam})` - ].join(' | '); - addFooterPageNumber(doc, footerText); - - const chunks: Buffer[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - doc.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); - - const pdfResponsePromise = new Promise((resolve, reject) => { - doc.on('end', () => { - const pdfBuffer = Buffer.concat(chunks); - resolve( - new Response(pdfBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename*=UTF-8''device-${devEui}-report-${startDateParam}-to-${endDateParam}.pdf`, - 'Content-Length': pdfBuffer.length.toString(), - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - } - }) - ); - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - doc.on('error', (err: any) => { - reject( - json( - { - error: 'Failed to generate PDF', - details: err.message, - device: devEui, - user: user?.email || 'Unknown user' - }, - { status: 500 } - ) - ); - }); - }); - - // Finalize once listeners are registered - doc.end(); - - return pdfResponsePromise; - } catch (err) { - console.error(`Error generating PDF for device ${params.devEui}:`, err); - return json( - { - error: 'Failed to generate PDF report', - details: err instanceof Error ? err.message : 'Unknown error', - device: params.devEui, - user: 'Unknown user' - }, - { status: 500 } - ); - } -}; diff --git a/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts b/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts deleted file mode 100644 index 6992b02e..00000000 --- a/src/routes/api/devices/[devEui]/pdf/drawRightAlertPanel.ts +++ /dev/null @@ -1,68 +0,0 @@ -// src/lib/pdf/drawRightAlertPanel.ts -import type { TableRow } from '$lib/pdf'; -import type { ReportAlertPoint } from '$lib/models/Report'; -import { checkMatch } from '$lib/pdf/utils'; - -type PDFDocument = InstanceType; - -export function drawRightAlertPanel(opts: { - doc: PDFDocument; - x: number; // left of the panel (e.g., 400) - y: number; // top of the panel (align with header) - width: number; // e.g., 180–200 - locale: string; - startLabel: string; - endLabel: string; - timezone: string; - samplingLabel: string; - sampleCount: number; - alertPoints: ReportAlertPoint[]; - validKeys: string[]; - dataRows: TableRow[]; -}): number { - const { doc, x, y, width, locale, alertPoints, validKeys, dataRows } = opts; - - const savedX = doc.x; - const savedY = doc.y; - - const pf0 = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }); - - doc.fontSize(8); - - let cy = y; - let bottomY = y; - - // Header lines - // cy = Math.max(cy, doc.y); // small gap - - // Alert points (count + % for their key) - for (const p of alertPoints) { - const idx = validKeys.indexOf(p.data_point_key); - if (idx === -1) continue; - - const vals = dataRows.map((row) => row.cells[idx]?.value as number); - const total = vals.length || 1; - const count = vals.filter((v) => checkMatch(v, p)).length; - const pct = count / total; - - const sw = 8; - const sh = 8; - doc - .save() - .rect(x, cy + 3, sw, sh) - .fill(p.hex_color || '#999') - .restore(); - doc.text(`${p.name ?? p.data_point_key}: ${count} (${pf0.format(pct)})`, x + sw + 6, cy, { - width: width - sw - 6 - }); - cy = Math.max(cy, doc.y); - } - - bottomY = cy; - - // IMPORTANT: do not leave global cursor moved - doc.x = savedX; - doc.y = savedY; - - return bottomY; -} diff --git a/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts b/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts deleted file mode 100644 index da60a9b5..00000000 --- a/src/routes/api/devices/[devEui]/pdf/drawSummaryPanel.ts +++ /dev/null @@ -1,122 +0,0 @@ -// src/lib/pdf/drawSummaryPanel.ts -import type { TableRow } from '$lib/pdf'; - -type PDFDocument = InstanceType; - -export type ClassificationBand = { label: string; min: number; max: number }; - -export type DrawSummaryPanelOpts = { - dataRows: TableRow[]; - validKeys: string[]; - primaryKey: string; - caption?: string; - samplingLabel: string; - dateRangeLabel: string; - maxLabel: string; - minLabel: string; - avgLabel: string; - stddevLabel: string; - displayStartLabel: string; - displayEndLabel: string; - bands?: ClassificationBand[]; - locale?: string; - fractionDigits?: number; - tableWidthPercent?: number; - lineColor?: string; - rowHeight?: number; -}; - -/** - * Draws the summary panel on the left side of the PDF. - * Returns the topY, firstRowY (y of the first row text), and bottomY. - */ -export function drawSummaryPanel( - doc: PDFDocument, - opts: DrawSummaryPanelOpts -): { topY: number; firstRowY: number; bottomY: number } { - const { - dataRows, - validKeys, - primaryKey, - caption, - samplingLabel, - dateRangeLabel, - maxLabel, - minLabel, - avgLabel, - stddevLabel, - displayStartLabel, - displayEndLabel, - locale = 'ja', - fractionDigits = 2, - tableWidthPercent = 0.5, - lineColor = '#000', - rowHeight = 16 - } = opts; - - if (!dataRows?.length || !validKeys?.length) { - const y0 = doc.y; - if (caption) doc.fontSize(12).text(caption); - doc.fontSize(9).fillColor('#555').text('No data'); - doc.fillColor('black'); - return { topY: y0, firstRowY: y0, bottomY: doc.y }; - } - - // pick index of primary key - const keyIndex = Math.max(0, validKeys.indexOf(primaryKey)); - const values: number[] = dataRows - .map((r) => r.cells?.[keyIndex]?.value as number) - .filter((v) => typeof v === 'number' && !Number.isNaN(v)); - - const total = values.length; - const min = values.length ? Math.min(...values) : NaN; - const max = values.length ? Math.max(...values) : NaN; - const avg = values.length ? values.reduce((a, b) => a + b, 0) / values.length : NaN; - const stddev = - values.length > 1 - ? Math.sqrt(values.reduce((acc, v) => acc + Math.pow(v - avg, 2), 0) / values.length) - : 0; - - const nf = new Intl.NumberFormat(locale, { maximumFractionDigits: fractionDigits }); - const pf = new Intl.NumberFormat(locale, { - style: 'percent', - maximumFractionDigits: fractionDigits - }); - - const lines: string[] = []; - lines.push(`${samplingLabel}: ${total}`); - lines.push(`${dateRangeLabel}: ${displayStartLabel} - ${displayEndLabel}`); - lines.push(`${maxLabel}: ${isFinite(max) ? nf.format(max) : '-'}`); - lines.push(`${minLabel}: ${isFinite(min) ? nf.format(min) : '-'}`); - lines.push(`${avgLabel}: ${isFinite(avg) ? nf.format(avg) : '-'}`); - lines.push(`${stddevLabel}: ${isFinite(stddev) ? nf.format(stddev) : '-'}`); - - const left = doc.page.margins.left; - const availableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; - const tableWidth = availableWidth * tableWidthPercent; - - if (caption) { - doc.fontSize(12).text(caption, left, doc.y, { width: tableWidth }); - doc.moveDown(0.3); - } - - const topY = doc.y; // start of the block - const firstRowY = topY + 3; // actual y we draw the first line at - - let y = topY; - doc.fontSize(10).fillColor('black'); - - for (let i = 0; i < lines.length; i++) { - doc.text(lines[i], left + 5, y + 3, { width: tableWidth - 10, ellipsis: true }); - doc - .moveTo(left, y + rowHeight) - .lineTo(left + tableWidth, y + rowHeight) - .lineWidth(1) - .strokeColor(lineColor) - .stroke(); - y += rowHeight; - } - - doc.y = y + 6; - return { topY, firstRowY, bottomY: doc.y }; -} diff --git a/src/routes/api/devices/[devEui]/pdf/server.test.ts b/src/routes/api/devices/[devEui]/pdf/server.test.ts deleted file mode 100644 index db7bfcb7..00000000 --- a/src/routes/api/devices/[devEui]/pdf/server.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @ts-nocheck -import { GET } from './+server'; -import { jest } from '@jest/globals'; -import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; -import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; -import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; - -jest.mock('$lib/pdf/pdfDataTable'); -jest.mock('$lib/pdf/pdfFooterPageNumber'); -jest.mock('$lib/pdf/pdfLineChartImage'); - -describe('GET /api/devices/[devEui]/pdf', () => { - it('should return a PDF response with correct headers', async () => { - const mockSupabase = { - auth: { - getUser: jest.fn().mockResolvedValue({ - data: { user: { id: 'user-id' } }, - error: null - }) - }, - from: jest.fn().mockReturnValue({ - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest - .fn() - .mockResolvedValue({ data: { full_name: 'Test User', employer: 'Test Company' } }) - }) - }) - }) - }; - - const params = { devEui: 'test-devEui' }; - const url = new URL( - 'http://localhost/api/devices/test-devEui/pdf?start=2025-05-01&end=2025-06-06&timezone=Asia/Tokyo&locale=ja' - ); - const locals = { supabase: mockSupabase }; - - const response = await GET({ params, url, locals }); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('application/pdf'); - expect(response.headers.get('Content-Disposition')).toContain('device-test-devEui-report.pdf'); - }); - - it('should return 401 if supabase is not provided', async () => { - const params = { devEui: 'test-devEui' }; - const url = new URL('http://localhost/api/devices/test-devEui/pdf'); - const locals = { supabase: null }; - - const response = await GET({ params, url, locals }); - - expect(response.status).toBe(401); - expect(await response.json()).toEqual({ error: 'Unauthorized access' }); - }); -}); diff --git a/src/routes/api/devices/[devEui]/permissions/+server.ts b/src/routes/api/devices/[devEui]/permissions/+server.ts deleted file mode 100644 index eba12527..00000000 --- a/src/routes/api/devices/[devEui]/permissions/+server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ForbiddenError, NotFoundError } from '$lib/errors/SpecificErrors'; - -// GET all permissions for a device -export const GET: RequestHandler = async ({ params, locals }) => { - try { - const devEui = params.devEui; - - // Create services with direct instantiation - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - - // Check if the user is authenticated - const session = locals.session; - if (!session) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - const { user } = session; - - // Get device to check if it exists - const device = await deviceService.getDeviceByEui(devEui); - if (!device) { - throw new NotFoundError('Device not found'); - } - - // For now, return basic permissions info - // TODO: Implement proper permission checking - const permissions: any[] = []; - - return json({ permissions }); - } catch (error) { - console.error('Error fetching device permissions:', error); - - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); - } - - if (error instanceof ForbiddenError) { - return json({ error: error.message }, { status: 403 }); - } - - return json({ error: 'Internal server error' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/permissions/[permissionId]/+server.ts b/src/routes/api/devices/[devEui]/permissions/[permissionId]/+server.ts deleted file mode 100644 index 8d93b22c..00000000 --- a/src/routes/api/devices/[devEui]/permissions/[permissionId]/+server.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceOwnersRepository } from '$lib/repositories/DeviceOwnersRepository'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ForbiddenError, NotFoundError } from '$lib/errors/SpecificErrors'; - -// PUT update a specific permission -export const PUT: RequestHandler = async ({ request, params, locals }) => { - try { - const devEui = params.devEui; - const permissionId = parseInt(params.permissionId); - - if (isNaN(permissionId)) { - return json({ error: 'Invalid permission ID' }, { status: 400 }); - } - - const { permission_level } = await request.json(); - - if (permission_level === undefined) { - return json({ error: 'Permission level is required' }, { status: 400 }); - } - - // Get services - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - const authService = new AuthService(locals.supabase, errorHandler); - - // Check if the user is authenticated - const sessionData = await authService.getSession(); - if (!sessionData) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - const { user } = sessionData; - - // Update the permission - const updatedPermission = await deviceRepo.updateDevicePermission( - permissionId, - permission_level - ); - - return json(updatedPermission); - } catch (error) { - console.error('Error updating device permission:', error); - - if (error instanceof ForbiddenError) { - return json({ error: error.message }, { status: 403 }); - } - - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); - } - - return json({ error: 'Failed to update device permission' }, { status: 500 }); - } -}; - -// DELETE remove a specific permission -export const DELETE: RequestHandler = async ({ params, locals }) => { - try { - const devEui = params.devEui; - const permissionId = parseInt(params.permissionId); - - if (isNaN(permissionId)) { - return json({ error: 'Invalid permission ID' }, { status: 400 }); - } - - // Get services - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - const deviceOwnersRepo = new DeviceOwnersRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - const authService = new AuthService(locals.supabase, errorHandler); - - // Check if the user is authenticated - const sessionData = await authService.getSession(); - if (!sessionData) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - const { user } = sessionData; - - // Get the permission to find the user to remove - const permissions = await deviceOwnersRepo.findByDeviceEui(devEui); - const permission = permissions.find((p: any) => p.id === permissionId); - - if (!permission) { - return json({ error: 'Permission not found' }, { status: 404 }); - } - - // Remove the user from the device - await deviceRepo.removeUserFromDevice(devEui, permission.user_id); - - return json({ success: true }); - } catch (error) { - console.error('Error deleting device permission:', error); - - if (error instanceof ForbiddenError) { - return json({ error: error.message }, { status: 403 }); - } - - if (error instanceof NotFoundError) { - return json({ error: error.message }, { status: 404 }); - } - - return json({ error: 'Failed to delete device permission' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/rules/+server.ts b/src/routes/api/devices/[devEui]/rules/+server.ts deleted file mode 100644 index fc74a08a..00000000 --- a/src/routes/api/devices/[devEui]/rules/+server.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { RuleService } from '$lib/services/RuleService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { toRuleDtos, toRuleWithCriteriaDto } from '$lib/dtos/RuleDto'; -import type { RuleInsert, RuleCriteriaInsert } from '$lib/models/Rule'; - -/** - * GET handler to retrieve rules for a specific device - */ -export const GET: RequestHandler = async ({ params, locals }) => { - try { - const devEui = params.devEui; - - if (!devEui) { - return json({ error: 'Device EUI is required' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - const rules = await ruleService.getRulesByDevice(devEui); - - return json({ rules: toRuleDtos(rules) }); - } catch (error) { - console.error('Error retrieving rules for device:', error); - return json({ error: 'Failed to retrieve rules' }, { status: 500 }); - } -}; - -/** - * POST handler to create a new rule for a device - */ -export const POST: RequestHandler = async ({ params, request, locals }) => { - try { - const devEui = params.devEui; - const data = await request.json(); - - if (!devEui) { - return json({ error: 'Device EUI is required' }, { status: 400 }); - } - - if (!data.rule || !data.criteria || !Array.isArray(data.criteria)) { - return json({ error: 'Rule and criteria array are required' }, { status: 400 }); - } - - // Prepare rule data with the device EUI - const ruleData: RuleInsert = { - ...data.rule, - dev_eui: devEui, - ruleGroupId: `rule-${devEui}-${Date.now()}` // Generate a unique rule group ID - }; - - // Prepare criteria data - const criteriaData: RuleCriteriaInsert[] = data.criteria; - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - // Create rule with its criteria - const createdRule = await ruleService.createRuleWithCriteria(ruleData, criteriaData); - - return json({ rule: toRuleWithCriteriaDto(createdRule) }, { status: 201 }); - } catch (error) { - console.error('Error creating rule for device:', error); - return json({ error: 'Failed to create rule' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/rules/[ruleGroupId]/criteria/+server.ts b/src/routes/api/devices/[devEui]/rules/[ruleGroupId]/criteria/+server.ts deleted file mode 100644 index 15cbe897..00000000 --- a/src/routes/api/devices/[devEui]/rules/[ruleGroupId]/criteria/+server.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { toRuleCriteriaDto } from '$lib/dtos/RuleDto'; -import type { RuleCriteriaInsert } from '$lib/models/Rule'; -import { RuleService } from '$lib/services/RuleService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -/** - * GET handler to retrieve all criteria for a rule group - */ -export const GET: RequestHandler = async ({ params, locals: { supabase } }) => { - try { - const ruleGroupId = params.ruleGroupId; - - if (!ruleGroupId) { - return json({ error: 'Rule group ID is required' }, { status: 400 }); - } - - // Create rule service with direct instantiation - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - const criteria = await ruleService.getRuleCriteriaByGroup(ruleGroupId); - - return json({ criteria: criteria.map(toRuleCriteriaDto) }); - } catch (error) { - console.error('Error retrieving rule criteria:', error); - return json({ error: 'Failed to retrieve rule criteria' }, { status: 500 }); - } -}; - -/** - * POST handler to add new criteria to an existing rule - */ -export const POST: RequestHandler = async ({ params, request, locals: { supabase } }) => { - try { - const ruleGroupId = params.ruleGroupId; - const data = await request.json(); - - if (!ruleGroupId) { - return json({ error: 'Rule group ID is required' }, { status: 400 }); - } - - if (!data.criteria) { - return json({ error: 'Criteria data is required' }, { status: 400 }); - } - - // Create rule service with direct instantiation - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - // Create new criteria with the rule group ID - const criteriaData: RuleCriteriaInsert = { - ...data.criteria, - ruleGroupId - }; - - const createdCriteria = await ruleService.createRuleCriteria(criteriaData); - - return json({ criteria: toRuleCriteriaDto(createdCriteria) }, { status: 201 }); - } catch (error) { - console.error('Error creating rule criteria:', error); - return json({ error: 'Failed to create rule criteria' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/rules/[ruleId]/+server.ts b/src/routes/api/devices/[devEui]/rules/[ruleId]/+server.ts deleted file mode 100644 index 6e298846..00000000 --- a/src/routes/api/devices/[devEui]/rules/[ruleId]/+server.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { RuleService } from '$lib/services/RuleService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { toRuleDto } from '$lib/dtos/RuleDto'; -import type { RuleUpdate } from '$lib/models/Rule'; - -/** - * GET handler to retrieve a specific rule - */ -export const GET: RequestHandler = async ({ params, locals }) => { - try { - const ruleId = parseInt(params.ruleId); - - if (isNaN(ruleId)) { - return json({ error: 'Invalid rule ID' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - const rule = await ruleService.getRuleById(ruleId); - - if (!rule) { - return json({ error: 'Rule not found' }, { status: 404 }); - } - - return json({ rule: toRuleDto(rule) }); - } catch (error) { - console.error('Error retrieving rule:', error); - return json({ error: 'Failed to retrieve rule' }, { status: 500 }); - } -}; - -/** - * PUT handler to update a specific rule - */ -export const PUT: RequestHandler = async ({ params, request, locals }) => { - try { - const ruleId = parseInt(params.ruleId); - const data = await request.json(); - - if (isNaN(ruleId)) { - return json({ error: 'Invalid rule ID' }, { status: 400 }); - } - - if (!data.rule) { - return json({ error: 'Rule data is required' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - // Update rule - const ruleData: RuleUpdate = data.rule; - const updatedRule = await ruleService.updateRule(ruleId, ruleData); - - if (!updatedRule) { - return json({ error: 'Rule not found' }, { status: 404 }); - } - - return json({ rule: toRuleDto(updatedRule) }); - } catch (error) { - console.error('Error updating rule:', error); - return json({ error: 'Failed to update rule' }, { status: 500 }); - } -}; - -/** - * DELETE handler to delete a specific rule - */ -export const DELETE: RequestHandler = async ({ params, locals }) => { - try { - const ruleId = parseInt(params.ruleId); - - if (isNaN(ruleId)) { - return json({ error: 'Invalid rule ID' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - const deleted = await ruleService.deleteRule(ruleId); - - if (!deleted) { - return json({ error: 'Failed to delete rule' }, { status: 500 }); - } - - return json({ success: true }); - } catch (error) { - console.error('Error deleting rule:', error); - return json({ error: 'Failed to delete rule' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/rules/criteria/[criteriaId]/+server.ts b/src/routes/api/devices/[devEui]/rules/criteria/[criteriaId]/+server.ts deleted file mode 100644 index e515e42b..00000000 --- a/src/routes/api/devices/[devEui]/rules/criteria/[criteriaId]/+server.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { RuleService } from '$lib/services/RuleService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { toRuleCriteriaDto } from '$lib/dtos/RuleDto'; -import type { RuleCriteriaUpdate } from '$lib/models/Rule'; - -/** - * PUT handler to update a specific rule criteria - */ -export const PUT: RequestHandler = async ({ params, request, locals }) => { - try { - const criteriaId = parseInt(params.criteriaId); - const data = await request.json(); - - if (isNaN(criteriaId)) { - return json({ error: 'Invalid criteria ID' }, { status: 400 }); - } - - if (!data.criteria) { - return json({ error: 'Criteria data is required' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - // Update criteria - const criteriaData: RuleCriteriaUpdate = data.criteria; - const updatedCriteria = await ruleService.updateRuleCriteria(criteriaId, criteriaData); - - if (!updatedCriteria) { - return json({ error: 'Criteria not found' }, { status: 404 }); - } - - return json({ criteria: toRuleCriteriaDto(updatedCriteria) }); - } catch (error) { - console.error('Error updating rule criteria:', error); - return json({ error: 'Failed to update rule criteria' }, { status: 500 }); - } -}; - -/** - * DELETE handler to delete a specific rule criteria - */ -export const DELETE: RequestHandler = async ({ params, locals }) => { - try { - const criteriaId = parseInt(params.criteriaId); - - if (isNaN(criteriaId)) { - return json({ error: 'Invalid criteria ID' }, { status: 400 }); - } - - // Create services directly - const errorHandler = new ErrorHandlingService(); - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const ruleService = new RuleService(ruleRepo); - - const deleted = await ruleService.deleteRuleCriteria(criteriaId); - - if (!deleted) { - return json({ error: 'Failed to delete criteria' }, { status: 500 }); - } - - return json({ success: true }); - } catch (error) { - console.error('Error deleting rule criteria:', error); - return json({ error: 'Failed to delete rule criteria' }, { status: 500 }); - } -}; diff --git a/src/routes/api/devices/[devEui]/status/+server.ts b/src/routes/api/devices/[devEui]/status/+server.ts deleted file mode 100644 index 58fdf2e8..00000000 --- a/src/routes/api/devices/[devEui]/status/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; - -export const GET: RequestHandler = async ({ params, locals: { safeGetSession, supabase } }) => { - const { devEui } = params; - const { session } = await safeGetSession(); - if (!session) { - throw error(401, 'Authentication required'); - } - if (!devEui) { - throw error(400, 'Device EUI is required'); - } - const svc = new DeviceDataService(supabase); - const latest = await svc.getLatestDeviceData(devEui); - if (!latest) { - throw error(404, 'No data'); - } - return json(latest); -}; diff --git a/src/routes/api/locations/+server.ts b/src/routes/api/locations/+server.ts deleted file mode 100644 index 965a4cec..00000000 --- a/src/routes/api/locations/+server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationService } from '$lib/services/LocationService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - // Create error handler - const errorHandler = new ErrorHandlingService(); - - // Create repositories with the per-request Supabase client - const locationRepo = new LocationRepository(locals.supabase, errorHandler); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - - // Create the location service with the repositories - const locationService = new LocationService(locationRepo, deviceRepo, errorHandler); - - // Get all basic location data without devices - const locations = await locationService.getAllLocations(); - - // Add a deviceCount property to each location - const enhancedLocations = await Promise.all( - locations.map(async (location) => { - try { - // Instead of fetching all device data, just get the count - const deviceCount = await locationService.getDeviceCountForLocation(location.location_id); - - return { - ...location, - deviceCount - }; - } catch (error) { - console.error(`Error getting device count for location ${location.location_id}:`, error); - return { - ...location, - deviceCount: 0, - error: 'Failed to get device count' - }; - } - }) - ); - - return json(enhancedLocations); - } catch (error) { - console.error('Error fetching locations:', error); - return json({ error: 'Failed to fetch location data' }, { status: 500 }); - } -}; diff --git a/src/routes/api/locations/[locationId]/devices/+server.ts b/src/routes/api/locations/[locationId]/devices/+server.ts deleted file mode 100644 index c79f1f37..00000000 --- a/src/routes/api/locations/[locationId]/devices/+server.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import type { DeviceType } from '$lib/models/Device'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; - -export const GET: RequestHandler = async ({ params, locals }) => { - try { - const locationId = parseInt(params.locationId); - - if (isNaN(locationId)) { - return json({ error: 'Invalid location ID' }, { status: 400 }); - } - - // Get error handler - const errorHandler = new ErrorHandlingService(); - - // Create repositories with per-request Supabase client - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - // Create services with repositories - const deviceService = new DeviceService(deviceRepo); - const deviceDataService = new DeviceDataService(locals.supabase, errorHandler); - - // Get devices for this location - now includes device type info directly - const devices = await deviceService.getDevicesByLocation(locationId); - - // Process devices in parallel with Promise.all for better performance - const devicesWithData = await Promise.all( - devices.map(async (device) => { - try { - // Cast device to access the nested device type data from the joined query - const deviceWithJoins = device as any; - // Extract device type from the joined data - const deviceType = deviceWithJoins.cw_device_type as DeviceType; - - let latestData = null; - - // Dynamically get latest data based on device type's data_table_v2 value - latestData = await deviceDataService.getLatestDeviceData(device.dev_eui); - - return { - ...device, - deviceType: deviceType, - latestData - }; - } catch (error) { - console.error(`Error processing device ${device.dev_eui}:`, error); - return { - ...device, - error: error instanceof Error ? error.message : 'Failed to fetch device data', - latestData: null - }; - } - }) - ); - - return json(devicesWithData); - } catch (error) { - console.error(`Error fetching devices for location ${params.locationId}:`, error); - return json({ error: 'Failed to fetch devices' }, { status: 500 }); - } -}; diff --git a/src/routes/api/locations/[locationId]/permissions/+server.ts b/src/routes/api/locations/[locationId]/permissions/+server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/api/notifications/test/+server.ts b/src/routes/api/notifications/test/+server.ts deleted file mode 100644 index f1dda438..00000000 --- a/src/routes/api/notifications/test/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { OneSignalService } from '$lib/server/OneSignalService'; - -/** - * Test endpoint to send a push to either provided externalUserIds or the Test Users segment. - * Secure this route (add auth / role checks) before using outside local dev. - * Uses env vars: PUBLIC_ONESIGNAL_APP_ID / PRIVATE_ONESIGNAL_REST_API_KEY - * POST body JSON: { contents: { en: "Hello" }, externalUserIds?: ["user123"], includedSegments?: ["Test Users"] } - */ -export const POST: RequestHandler = async ({ request }) => { - const errorHandler = new ErrorHandlingService(); - const oneSignal = new OneSignalService(errorHandler); - try { - const body = await request.json(); - // Basic validation - if (!body.contents) { - return new Response(JSON.stringify({ error: 'contents required' }), { status: 400 }); - } - - const result = await oneSignal.sendPush({ - contents: body.contents, - headings: body.headings, - externalUserIds: body.externalUserIds, - includedSegments: body.includedSegments || (!body.externalUserIds && ['Test Users']), - data: body.data, - iosAttachments: body.iosAttachments, - bigPicture: body.bigPicture, - name: body.name || 'Test API Push' - }); - - return new Response(JSON.stringify({ success: true, result }), { status: 200 }); - } catch (err) { - return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 }); - } -}; diff --git a/src/routes/api/permissions/levels/+server.ts b/src/routes/api/permissions/levels/+server.ts deleted file mode 100644 index 995cf25b..00000000 --- a/src/routes/api/permissions/levels/+server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - // Check if the user is authenticated - const { - data: { user } - } = await locals.supabase.auth.getUser(); - if (!user) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - // Fetch permission levels from the database - const { data, error } = await locals.supabase - .from('cw_permission_level_types') - .select('*') - .order('permission_level_id', { ascending: true }); - - if (error) { - console.error('Error fetching permission levels:', error); - return json({ error: 'Failed to fetch permission levels' }, { status: 500 }); - } - - return json(data); - } catch (error) { - console.error('Error fetching permission levels:', error); - return json({ error: 'Failed to fetch permission levels' }, { status: 500 }); - } -}; diff --git a/src/routes/api/spec/+server.ts b/src/routes/api/spec/+server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/api/swagger.json b/src/routes/api/swagger.json deleted file mode 100644 index d29a7b10..00000000 --- a/src/routes/api/swagger.json +++ /dev/null @@ -1,1082 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "CropWatch API", - "description": "API for managing agricultural IoT devices and sensor data", - "version": "1.0.0", - "contact": { - "name": "CropWatch Support", - "email": "support@cropwatch.io" - } - }, - "servers": [ - { - "url": "/api", - "description": "CropWatch API server" - } - ], - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } - }, - "schemas": { - "ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error message" - }, - "status": { - "type": "integer", - "description": "HTTP status code" - } - } - }, - "Device": { - "type": "object", - "properties": { - "dev_eui": { - "type": "string", - "description": "Device EUI (unique identifier)" - }, - "name": { - "type": "string", - "description": "Device name" - }, - "type": { - "type": "integer", - "description": "Device type ID" - }, - "location_id": { - "type": "integer", - "description": "Location ID where device is installed" - }, - "installed_at": { - "type": "string", - "format": "date-time", - "description": "Date and time when the device was installed" - }, - "lat": { - "type": "number", - "format": "float", - "description": "Latitude coordinate" - }, - "long": { - "type": "number", - "format": "float", - "description": "Longitude coordinate" - }, - "serial_number": { - "type": "string", - "description": "Serial number of the device" - }, - "upload_interval": { - "type": "integer", - "description": "Upload interval in seconds" - }, - "last_seen": { - "type": "string", - "format": "date-time", - "description": "Last time the device connected" - }, - "battery_level": { - "type": "number", - "format": "float", - "description": "Current battery level percentage" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Date and time when the device was created in the system" - } - } - }, - "DeviceWithType": { - "allOf": [ - { - "$ref": "#/components/schemas/Device" - }, - { - "type": "object", - "properties": { - "deviceType": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "data_type": { - "type": "string", - "enum": ["soil", "air"] - } - } - } - } - } - ] - }, - "CreateDeviceDto": { - "type": "object", - "required": ["dev_eui", "name"], - "properties": { - "dev_eui": { - "type": "string", - "description": "Device EUI (unique identifier)" - }, - "name": { - "type": "string", - "description": "Device name" - }, - "type": { - "type": "integer", - "description": "Device type ID" - }, - "location_id": { - "type": "integer", - "description": "Location ID where device is installed" - }, - "lat": { - "type": "number", - "format": "float", - "description": "Latitude coordinate" - }, - "long": { - "type": "number", - "format": "float", - "description": "Longitude coordinate" - }, - "serial_number": { - "type": "string", - "description": "Serial number of the device" - }, - "upload_interval": { - "type": "integer", - "description": "Upload interval in seconds" - } - } - }, - "UpdateDeviceDto": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Device name" - }, - "type": { - "type": "integer", - "description": "Device type ID" - }, - "location_id": { - "type": "integer", - "description": "Location ID where device is installed" - }, - "lat": { - "type": "number", - "format": "float", - "description": "Latitude coordinate" - }, - "long": { - "type": "number", - "format": "float", - "description": "Longitude coordinate" - }, - "serial_number": { - "type": "string", - "description": "Serial number of the device" - }, - "upload_interval": { - "type": "integer", - "description": "Upload interval in seconds" - } - } - }, - "Location": { - "type": "object", - "properties": { - "location_id": { - "type": "integer", - "description": "Unique identifier for the location" - }, - "name": { - "type": "string", - "description": "Location name" - }, - "description": { - "type": "string", - "description": "Location description" - }, - "address": { - "type": "string", - "description": "Physical address of the location" - }, - "lat": { - "type": "number", - "format": "float", - "description": "Latitude coordinate" - }, - "long": { - "type": "number", - "format": "float", - "description": "Longitude coordinate" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Date and time when the location was created" - }, - "deviceCount": { - "type": "integer", - "description": "Number of devices assigned to this location" - } - } - }, - "CreateLocationDto": { - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "description": "Location name" - }, - "description": { - "type": "string", - "description": "Location description" - }, - "address": { - "type": "string", - "description": "Physical address of the location" - }, - "lat": { - "type": "number", - "format": "float", - "description": "Latitude coordinate" - }, - "long": { - "type": "number", - "format": "float", - "description": "Longitude coordinate" - } - } - }, - "SensorData": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when data was recorded" - }, - "dev_eui": { - "type": "string", - "description": "Device EUI (unique identifier)" - } - }, - "additionalProperties": { - "type": "number", - "description": "Sensor readings with dynamic properties based on the device type" - } - }, - "SoilData": { - "allOf": [ - { - "$ref": "#/components/schemas/SensorData" - }, - { - "type": "object", - "properties": { - "temperature_c": { - "type": "number", - "description": "Temperature in Celsius" - }, - "moisture": { - "type": "number", - "description": "Soil moisture percentage" - }, - "ph": { - "type": "number", - "description": "Soil pH level" - } - } - } - ] - }, - "AirData": { - "allOf": [ - { - "$ref": "#/components/schemas/SensorData" - }, - { - "type": "object", - "properties": { - "temperature_c": { - "type": "number", - "description": "Temperature in Celsius" - }, - "humidity": { - "type": "number", - "description": "Relative humidity percentage" - }, - "co2": { - "type": "number", - "description": "CO2 level in ppm" - } - } - } - ] - }, - "Rule": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Rule identifier" - }, - "name": { - "type": "string", - "description": "Rule name" - }, - "description": { - "type": "string", - "description": "Rule description" - }, - "device_id": { - "type": "string", - "description": "Device EUI this rule applies to" - }, - "active": { - "type": "boolean", - "description": "Whether the rule is active" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the rule was created" - } - } - }, - "CreateRuleDto": { - "type": "object", - "required": ["name", "device_id"], - "properties": { - "name": { - "type": "string", - "description": "Rule name" - }, - "description": { - "type": "string", - "description": "Rule description" - }, - "device_id": { - "type": "string", - "description": "Device EUI this rule applies to" - }, - "active": { - "type": "boolean", - "description": "Whether the rule is active" - } - } - }, - "Permission": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Permission identifier" - }, - "user_id": { - "type": "string", - "description": "User ID" - }, - "resource_id": { - "type": "string", - "description": "Resource ID (device EUI or location ID)" - }, - "resource_type": { - "type": "string", - "description": "Type of resource (device or location)" - }, - "permission_level": { - "type": "integer", - "description": "Permission level ID" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the permission was created" - } - } - }, - "User": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User ID" - }, - "email": { - "type": "string", - "format": "email", - "description": "User email" - }, - "full_name": { - "type": "string", - "description": "User's full name" - } - } - }, - "LoginRequest": { - "type": "object", - "required": ["email", "password"], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "User email" - }, - "password": { - "type": "string", - "description": "User password" - } - } - }, - "RegisterRequest": { - "type": "object", - "required": ["email", "password", "full_name"], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "User email" - }, - "password": { - "type": "string", - "description": "User password" - }, - "full_name": { - "type": "string", - "description": "User's full name" - } - } - } - }, - "responses": { - "UnauthorizedError": { - "description": "Access token is missing or invalid", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "ForbiddenError": { - "description": "User does not have permission to access this resource", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "NotFoundError": { - "description": "The requested resource was not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "paths": { - "/devices/{devEui}": { - "get": { - "summary": "Get device by EUI", - "description": "Returns detailed information about a specific device", - "operationId": "getDeviceByEui", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Device details including type information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceWithType" - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - }, - "put": { - "summary": "Update device", - "description": "Updates the specified device", - "operationId": "updateDevice", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Device update data", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDeviceDto" - } - } - } - }, - "responses": { - "200": { - "description": "Device updated successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Device" - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - } - } - }, - "delete": { - "summary": "Delete device", - "description": "Deletes the specified device", - "operationId": "deleteDevice", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Device deleted successfully" - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - } - } - } - }, - "/devices/{devEui}/data": { - "get": { - "summary": "Get device data", - "description": "Returns sensor data for a specific device within a date range", - "operationId": "getDeviceData", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - }, - { - "name": "start", - "in": "query", - "required": true, - "description": "Start date (YYYY-MM-DD)", - "schema": { - "type": "string", - "format": "date" - } - }, - { - "name": "end", - "in": "query", - "required": true, - "description": "End date (YYYY-MM-DD)", - "schema": { - "type": "string", - "format": "date" - } - } - ], - "responses": { - "200": { - "description": "Sensor data for the device", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/SoilData" - }, - { - "$ref": "#/components/schemas/AirData" - } - ] - } - } - } - } - }, - "400": { - "description": "Bad request - Missing or invalid date parameters", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - } - }, - "/locations": { - "get": { - "summary": "Get all locations", - "description": "Returns all locations with device count", - "operationId": "getAllLocations", - "responses": { - "200": { - "description": "List of locations", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Location" - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - }, - "post": { - "summary": "Create location", - "description": "Creates a new location", - "operationId": "createLocation", - "requestBody": { - "description": "Location data", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLocationDto" - } - } - } - }, - "responses": { - "201": { - "description": "Location created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Location" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - } - }, - "/locations/{locationId}/devices": { - "get": { - "summary": "Get devices by location", - "description": "Returns devices for a specific location", - "operationId": "getDevicesByLocation", - "parameters": [ - { - "name": "locationId", - "in": "path", - "required": true, - "description": "Location ID", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "List of devices at the location", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Device" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - } - } - }, - "post": { - "summary": "Add device to location", - "description": "Adds a new device to a location", - "operationId": "addDeviceToLocation", - "parameters": [ - { - "name": "locationId", - "in": "path", - "required": true, - "description": "Location ID", - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "description": "Device data", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDeviceDto" - } - } - } - }, - "responses": { - "201": { - "description": "Device added successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Device" - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - } - } - } - }, - "/auth/login": { - "post": { - "summary": "User login", - "description": "Authenticates a user and returns a JWT token", - "operationId": "login", - "security": [], - "requestBody": { - "description": "Login credentials", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Login successful, returns session token", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "session": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "expires_at": { - "type": "string", - "format": "date-time" - } - } - } - } - } - } - } - }, - "401": { - "description": "Invalid credentials", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/auth/register": { - "post": { - "summary": "User registration", - "description": "Registers a new user", - "operationId": "register", - "security": [], - "requestBody": { - "description": "Registration data", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Registration successful", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - }, - "400": { - "description": "Invalid registration data", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/auth/logout": { - "post": { - "summary": "User logout", - "description": "Logs out a user by invalidating the current session", - "operationId": "logout", - "responses": { - "200": { - "description": "Logout successful" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - } - }, - "/auth/status": { - "get": { - "summary": "Authentication status", - "description": "Returns the current user and authentication status", - "operationId": "getAuthStatus", - "responses": { - "200": { - "description": "Current authentication status", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "authenticated": { - "type": "boolean" - } - } - } - } - } - } - } - } - }, - "/devices/{devEui}/rules": { - "get": { - "summary": "Get device rules", - "description": "Returns all rules for a specific device", - "operationId": "getDeviceRules", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List of rules for the device", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Rule" - } - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - } - } - }, - "post": { - "summary": "Create device rule", - "description": "Creates a new rule for a device", - "operationId": "createDeviceRule", - "parameters": [ - { - "name": "devEui", - "in": "path", - "required": true, - "description": "Device EUI", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "description": "Rule data", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateRuleDto" - } - } - } - }, - "responses": { - "201": { - "description": "Rule created successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Rule" - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFoundError" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "403": { - "$ref": "#/components/responses/ForbiddenError" - } - } - } - } - } -} diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts deleted file mode 100644 index 8fcdfc1f..00000000 --- a/src/routes/api/users/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { UserRepository } from '$lib/repositories/UserRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - // Create error handler and repository - const errorHandler = new ErrorHandlingService(); - const userRepo = new UserRepository(locals.supabase, errorHandler); - - // Check if the user is authenticated - const { - data: { user } - } = await locals.supabase.auth.getUser(); - if (!user) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - // Fetch user profiles using the repository - const users = await userRepo.findAll(); - - return json(users); - } catch (error) { - const errorHandler = new ErrorHandlingService(); - errorHandler.logError(error as Error); - return json({ error: 'Failed to fetch users' }, { status: 500 }); - } -}; diff --git a/src/routes/api/webhook/stripe/+server.ts b/src/routes/api/webhook/stripe/+server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/app.css b/src/routes/app.css deleted file mode 100644 index fc129729..00000000 --- a/src/routes/app.css +++ /dev/null @@ -1,115 +0,0 @@ -@import 'tailwindcss'; - -/* Removed @layerstack/tailwind and svelte-ux imports */ - -:root { - --color-primary: var(--color-emerald-600); - --color-secondary: var(--color-blue-500); -} - -/* - The default border color has changed to `currentColor` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. -*/ -@layer base { - - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: color-mix(in oklab, var(--color-surface-content) 20%, transparent); - outline-color: color-mix(in oklab, var(--color-surface-content) 20%, transparent); - } -} - -html { - @apply bg-surface-200 accent-primary; -} - -nav h1 { - @apply text-surface-content bg-surface-200 mt-4 border-t border-b py-2 pl-4 text-sm font-bold; -} - -nav h2 { - @apply text-surface-content pt-4 pb-2 pl-4 text-xs font-bold; -} - -.prose { - h1 { - @apply text-2xl font-extrabold; - } - - h2 { - @apply mt-3 text-xl font-bold; - } - - h3 { - @apply text-lg font-semibold; - } -} - -main :is(h1, h2, h3):not(.prose *, .related *, .ApiDocs *) { - scroll-margin-top: calc(var(--headerHeight) + 148px); - /* app header + docs header */ -} - -main h1:not(.prose *, .related *, .ApiDocs *) { - @apply mt-4 mb-2 border-b pb-1 text-xl font-semibold; -} - -main h2:not(.prose *, .related *, .ApiDocs *) { - @apply mt-4 mb-1 text-lg font-semibold; -} - -main h3:not(.prose *, .related *, .ApiDocs *) { - @apply text-surface-content/50 mb-1 text-xs; -} - -main :not(.prose) h2+h3 { - @apply -mt-1; -} - -main small { - @apply text-surface-content/50 inline-block text-xs; -} - -.TableOfContents small { - @apply hidden; -} - -/* Code/Preview backgrounds */ -pre[class*='language-'] { - @apply bg-surface-content; -} - -.dark pre[class*='language-'] { - @apply bg-surface-300; -} - -nav { - @apply bg-surface-300; - @apply flex; -} - -nav a { - @apply text-surface-content; -} - -div:has(> .Header) { - @apply border-surface-300 border-b; -} - -/* Buttons */ - -button { - @apply cursor-pointer; -} - -button:disabled { - @apply cursor-not-allowed; -} \ No newline at end of file diff --git a/src/routes/app/+page.server.ts b/src/routes/app/+page.server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/app/+page.svelte b/src/routes/app/+page.svelte deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/app/account-settings/+layout.svelte b/src/routes/app/account-settings/+layout.svelte deleted file mode 100644 index 26bb2d14..00000000 --- a/src/routes/app/account-settings/+layout.svelte +++ /dev/null @@ -1,165 +0,0 @@ - - -
    - -
    -
    -

    {$_('Device Settings')}

    - -
    -
    - {@render children()} - - -
    -

    {$_('Push Notifications')}

    -

    - {#if pushPermission === 'granted'} - {$_('You have already enabled push notifications.')} - {:else if pushPermission === 'denied'} - {$_( - 'You have blocked notifications in your browser settings. Please allow them in the browser site settings to enable.' - )} - {:else} - {$_( - 'Enable push notifications to receive real-time alerts. You will see a browser prompt after clicking the button below.' - )} - {/if} -

    -
    - - - {$_('Browser Permission:')} - {pushPermission || 'n/a'} | {$_('SDK Loaded:')} - {oneSignalReady ? 'yes' : 'no'} - -
    - {#if subscriptionState} -
    - {$_('Subscription State (debug)')} -
    {subscriptionState}
    -
    - {/if} -
      -
    • - {$_('No prompt? You may have previously denied it; check site settings in your browser.')} -
    • -
    • {$_('Ensure the site is served over HTTPS (required for web push).')}
    • -
    • {$_('iOS Safari requires Add to Home Screen before prompting (iOS 16.4+).')}
    • -
    • {$_('Incognito/private browsing cannot subscribe to push.')}
    • -
    -
    -
    -
    - - diff --git a/src/routes/app/account-settings/display-settings/+page.svelte b/src/routes/app/account-settings/display-settings/+page.svelte deleted file mode 100644 index 9a1374f4..00000000 --- a/src/routes/app/account-settings/display-settings/+page.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - -
    - -
    -
    -

    - 🌍 {$_('Units & Display Settings')} -

    -

    - {$_('Choose your preferred units of measurement and display options for your account.')} -

    -
    -
    -
    - - -
    - -
    -

    - {$_('Unit Preferences')} -

    - -
    - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - -
    -
    -
    - - -
    -

    - {$_('Display Preferences')} -

    - -
    - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - -
    -
    -
    -
    -
    diff --git a/src/routes/app/account-settings/general/+page.server.ts b/src/routes/app/account-settings/general/+page.server.ts deleted file mode 100644 index cfc69506..00000000 --- a/src/routes/app/account-settings/general/+page.server.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { - const { session } = await safeGetSession(); - if (!session) { - redirect(303, '/'); - } - const { data: profile } = await supabase - .from('profiles') - .select(`username, full_name, website, avatar_url, line_id`) - .eq('id', session.user.id) - .single(); - return { session, profile }; -}; -export const actions: Actions = { - update: async ({ request, locals: { supabase, safeGetSession } }) => { - const formData = await request.formData(); - const fullName = formData.get('fullName'); - const username = formData.get('username'); - const website = formData.get('website'); - const avatarUrl = formData.get('avatarUrl'); - const { session } = await safeGetSession(); - - if (!session?.user) { - return fail(401, { error: 'Authentication required' }); - } - - if ( - typeof fullName !== 'string' || - typeof username !== 'string' || - typeof website !== 'string' || - typeof avatarUrl !== 'string' - ) { - return fail(400, { error: 'Invalid form submission' }); - } - - const payload = { - id: session.user.id, - full_name: fullName, - username, - website, - avatar_url: avatarUrl, - updated_at: new Date().toISOString() - }; - - const { error } = await supabase.from('profiles').upsert([payload]); - if (error) { - return fail(500, { - fullName, - username, - website, - avatarUrl - }); - } - return { - fullName, - username, - website, - avatarUrl - }; - }, - disconnectLine: async ({ locals: { supabase, safeGetSession } }) => { - const { session } = await safeGetSession(); - if (!session) redirect(303, '/'); - await supabase - .from('profiles') - .update({ line_id: null, updated_at: new Date().toISOString() }) - .eq('id', session.user.id); - return { lineDisconnected: true }; - }, - signout: async ({ locals: { supabase, safeGetSession } }) => { - const { session } = await safeGetSession(); - if (session) { - await supabase.auth.signOut(); - redirect(303, '/'); - } - } -}; diff --git a/src/routes/app/account-settings/general/+page.svelte b/src/routes/app/account-settings/general/+page.svelte deleted file mode 100644 index 1c1f6c9c..00000000 --- a/src/routes/app/account-settings/general/+page.svelte +++ /dev/null @@ -1,256 +0,0 @@ - - - - {$_('General Account Settings')} - - -
    - -
    -
    -

    - ⚙️ {$_('General Account Settings')} -

    -

    - {$_('Settings that affect your entire account, including your profile and preferences.')} -

    -
    -
    -
    - - -
    - -
    -
    -

    - {$_('Profile Information')} -

    - -
    - -
    - { - profileForm.requestSubmit(); - }} - /> -
    -

    - {$_('Profile Picture')} -

    -

    - {$_('Click to upload a new profile picture')} -

    -
    -
    - - -
    - - -

    - {$_('Your email address cannot be changed')} -

    -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - - -
    - -
    -
    - - -
    -

    - LINE - {#if profile?.line_id} - {$_('Connected')} - {:else} - {$_('Not Connected')} - {/if} -

    -

    - {$_('Connect your LINE account to receive messages and notifications.')} -

    - {#if profile?.line_id} -
    - -
    -
    - {$_('LINE User ID')}: {profile.line_id} -
    - {:else} - - {/if} -
    -
    -
    - - -
    -
    -

    - {$_('Account Actions')} -

    - -
    - -
    - -
    - - -
    - {$_('Need help with your account?')} - - {$_('Contact support')} - -
    -
    -
    -
    -
    -
    diff --git a/src/routes/app/account-settings/general/line/callback/+server.ts b/src/routes/app/account-settings/general/line/callback/+server.ts deleted file mode 100644 index fd2e3a94..00000000 --- a/src/routes/app/account-settings/general/line/callback/+server.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { redirect, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { PUBLIC_LINE_CHANNEL_ID } from '$env/static/public'; -import { LINE_CHANNEL_SECRET } from '$env/static/private'; - -interface LineTokenResponse { - access_token: string; - expires_in: number; - id_token: string; - refresh_token?: string; - scope: string; - token_type: string; -} -interface LineIdTokenPayload { - sub: string; // LINE user ID - aud: string; - iss: string; - exp: number; - iat: number; - name?: string; - picture?: string; -} - -export const GET: RequestHandler = async ({ - url, - cookies, - locals: { supabase, safeGetSession } -}) => { - const { session } = await safeGetSession(); - if (!session) redirect(303, '/auth/login'); - - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const err = url.searchParams.get('error'); - if (err) - return redirect(303, `/app/account-settings/general?line_error=${encodeURIComponent(err)}`); - if (!code || !state) throw error(400, 'Missing code/state'); - - const storedState = cookies.get('line_oauth_state'); - const codeVerifier = cookies.get('line_oauth_verifier'); - if (!storedState || storedState !== state || !codeVerifier) throw error(400, 'State mismatch'); - - // Clear cookies - cookies.delete('line_oauth_state', { path: '/' }); - cookies.delete('line_oauth_verifier', { path: '/' }); - - const clientId = PUBLIC_LINE_CHANNEL_ID; - const clientSecret = LINE_CHANNEL_SECRET; - if (!clientId || !clientSecret) throw error(500, 'LINE env missing'); - - const redirectUri = `${url.origin}/app/account-settings/general/line/callback`; - - const tokenRes = await fetch('https://api.line.me/oauth2/v2.1/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - client_secret: clientSecret, - code_verifier: codeVerifier - }) - }); - if (!tokenRes.ok) { - const text = await tokenRes.text(); - return redirect( - 303, - `/app/account-settings/general?line_error=token_exchange_failed&detail=${encodeURIComponent(text)}` - ); - } - const tokenJson = (await tokenRes.json()) as LineTokenResponse; - if (!tokenJson.id_token) - return redirect(303, '/app/account-settings/general?line_error=missing_id_token'); - - // Decode (no signature verify yet) - const [, payloadB64] = tokenJson.id_token.split('.'); - if (!payloadB64) return redirect(303, '/app/account-settings/general?line_error=bad_id_token'); - const payloadJson = JSON.parse( - Buffer.from(payloadB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8') - ) as LineIdTokenPayload; - const lineUserId = payloadJson.sub; - if (!lineUserId) - return redirect(303, '/app/account-settings/general?line_error=missing_line_user'); - - const { error: upErr } = await supabase - .from('profiles') - .update({ line_id: lineUserId, updated_at: new Date().toISOString() }) - .eq('id', session.user.id); - if (upErr) - return redirect( - 303, - `/app/account-settings/general?line_error=save_failed&detail=${encodeURIComponent(upErr.message)}` - ); - - return redirect(303, '/app/account-settings/general?line_connected=1'); -}; diff --git a/src/routes/app/account-settings/general/line/connect/+server.ts b/src/routes/app/account-settings/general/line/connect/+server.ts deleted file mode 100644 index 2e2398ab..00000000 --- a/src/routes/app/account-settings/general/line/connect/+server.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { redirect, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { PUBLIC_LINE_CHANNEL_ID } from '$env/static/public'; - -// Utility: base64url encode ArrayBuffer -function b64url(buffer: ArrayBuffer) { - return Buffer.from(buffer) - .toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); -} -// PKCE code verifier (43-128 chars) -function generateCodeVerifier() { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return b64url(bytes.buffer); // already url-safe -} - -export const GET: RequestHandler = async ({ url, cookies, locals: { safeGetSession } }) => { - const { session } = await safeGetSession(); - if (!session) redirect(303, '/auth/login'); - - const clientId = PUBLIC_LINE_CHANNEL_ID; - if (!clientId) throw error(500, 'Missing PUBLIC_LINE_CHANNEL_ID'); - - const state = crypto.randomUUID(); - const codeVerifier = generateCodeVerifier(); - const challengeBuffer = await crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(codeVerifier) - ); - const codeChallenge = b64url(challengeBuffer); - - // Persist values in httpOnly cookies (10 min lifetime) - const cookieOpts = { - path: '/', - httpOnly: true, - sameSite: 'lax' as const, - secure: url.protocol === 'https:', - maxAge: 600 - }; - cookies.set('line_oauth_state', state, cookieOpts); - cookies.set('line_oauth_verifier', codeVerifier, cookieOpts); - - const redirectUri = `${url.origin}/app/account-settings/general/line/callback`; - - const authUrl = new URL('https://access.line.me/oauth2/v2.1/authorize'); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('client_id', clientId); - authUrl.searchParams.set('redirect_uri', redirectUri); - authUrl.searchParams.set('state', state); - authUrl.searchParams.set('scope', 'profile openid'); - authUrl.searchParams.set('prompt', 'consent'); - authUrl.searchParams.set('code_challenge', codeChallenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); - - return redirect(302, authUrl.toString()); -}; diff --git a/src/routes/app/account-settings/payment/+page.server.ts b/src/routes/app/account-settings/payment/+page.server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/app/account-settings/payment/+page.svelte b/src/routes/app/account-settings/payment/+page.svelte deleted file mode 100644 index b57594c7..00000000 --- a/src/routes/app/account-settings/payment/+page.svelte +++ /dev/null @@ -1,322 +0,0 @@ - - -
    - -
    -
    -

    💳 Account Payment Settings

    -

    - Add or update your payment method securely with Stripe. -

    -
    -
    - - - ➡️ Add Subscription - - - -
    - -
    -

    - 📋 Active Subscriptions -

    -

    - Manage your active subscriptions and assign devices. -

    - - {#if subscriptions.length === 0} -
    - - - -

    No subscriptions

    -

    - You don't have any active subscriptions. -

    -
    - {:else} -
    - {#each subscriptions as subscription (subscription.id)} -
    -
    -
    -

    - {subscription.name} -

    -
    - - ${subscription.amount}/{subscription.interval} - - - {subscription.status} - - {#if subscription.pausedUntil} - - Paused until {subscription.pausedUntil} - - {/if} -
    -

    - Next billing: {subscription.current_period_end} -

    -
    -
    - -
    - -
    - -
    - -
    - - - -
    -
    -
    - {/each} -
    - {/if} -
    -
    -
    diff --git a/src/routes/app/account-settings/payment/PauseSubscription.svelte b/src/routes/app/account-settings/payment/PauseSubscription.svelte deleted file mode 100644 index a0dae1e8..00000000 --- a/src/routes/app/account-settings/payment/PauseSubscription.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - -
    -

    - ⏸️ Pause All Subscriptions -

    -

    - Temporarily pause all your active subscriptions. You won't be charged during the pause period. -

    - -
    -
    - - -
    - - -
    -
    diff --git a/src/routes/app/account-settings/payment/add-subscription/+page.server.ts b/src/routes/app/account-settings/payment/add-subscription/+page.server.ts deleted file mode 100644 index d096d644..00000000 --- a/src/routes/app/account-settings/payment/add-subscription/+page.server.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; -import type { Actions, PageServerLoad } from './$types'; -import Stripe from 'stripe'; -import { PRIVATE_STRIPE_SECRET_KEY } from '$env/static/private'; -import { PUBLIC_DOMAIN } from '$env/static/public'; - -// This is your test secret API key. -const stripe = new Stripe(PRIVATE_STRIPE_SECRET_KEY, { - apiVersion: '2025-08-27.basil', // Use the latest API version or a specific one - typescript: true -}); - -const YOUR_DOMAIN = PUBLIC_DOMAIN; - -export const load: PageServerLoad = async () => { - try { - // Fetch all active subscription products with their prices - const products = await stripe.products.list({ - active: true, - expand: ['data.default_price'] - }); - - // Fetch all prices for subscription products - const prices = await stripe.prices.list({ - active: true, - type: 'recurring', - expand: ['data.product'] - }); - - // Group prices by product - const subscriptionProducts = products.data - .filter((product) => product.type === 'service' || product.type === 'good') - .map((product) => { - const productPrices = prices.data.filter( - (price) => typeof price.product === 'object' && price.product.id === product.id - ); - - return { - id: product.id, - name: product.name, - description: product.description, - images: product.images, - metadata: product.metadata, - prices: productPrices.map((price) => ({ - id: price.id, - unit_amount: price.unit_amount, - currency: price.currency, - recurring: price.recurring, - lookup_key: price.lookup_key, - nickname: price.nickname - })) - }; - }) - .filter((product) => product.prices.length > 0); // Only include products with prices - - return { - subscriptionProducts - }; - } catch (error) { - console.error('Error loading subscription products:', error); - return { - subscriptionProducts: [], - error: 'Failed to load subscription products' - }; - } -}; - -export const actions: Actions = { - 'create-checkout-session': async ({ request, url, locals }) => { - const formData = await request.formData(); - const price_id = formData.get('price_id') as string; - - try { - // Get the current user - const { session: userSession, user } = await locals.safeGetSession(); - - if (!userSession || !user) { - return { - error: 'You must be logged in to create a subscription' - }; - } - - // Create or retrieve customer - let customerId; - try { - const customers = await stripe.customers.list({ - email: user.email, - limit: 1 - }); - - if (customers.data.length > 0) { - customerId = customers.data[0].id; - } else { - const customer = await stripe.customers.create({ - email: user.email, - name: - user.user_metadata?.full_name || - `${user.user_metadata?.first_name || ''} ${user.user_metadata?.last_name || ''}`.trim(), - metadata: { - user_id: user.id - } - }); - customerId = customer.id; - } - } catch (customerError) { - console.error('Error handling customer:', customerError); - return { - error: 'Failed to create or retrieve customer' - }; - } - - const session = await stripe.checkout.sessions.create({ - customer: customerId, - billing_address_collection: 'auto', - line_items: [ - { - price: price_id, - quantity: 1 - } - ], - mode: 'subscription', - success_url: `${YOUR_DOMAIN}/app/account-settings/payment/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${YOUR_DOMAIN}/app/account-settings/payment/cancel`, - metadata: { - user_id: user.id - } - }); - - if (session.url) { - return { - success: true, - redirectUrl: session.url - }; - } else { - return { - error: 'No session URL returned from Stripe' - }; - } - } catch (error) { - console.error('Error creating checkout session:', error); - return { - error: 'Failed to create checkout session' - }; - } - }, - - 'create-portal-session': async ({ request }) => { - const formData = await request.formData(); - const session_id = formData.get('session_id') as string; - - try { - // For demonstration purposes, we're using the Checkout session to retrieve the customer ID. - // Typically this is stored alongside the authenticated user in your database. - const checkoutSession = await stripe.checkout.sessions.retrieve(session_id); - - if (!checkoutSession.customer) { - return { - error: 'No customer found in checkout session' - }; - } - - // This is the url to which the customer will be redirected when they're done - // managing their billing with the portal. - const returnUrl = `${YOUR_DOMAIN}/app/account-settings/payment`; - - const portalSession = await stripe.billingPortal.sessions.create({ - customer: checkoutSession.customer as string, - return_url: returnUrl - }); - - return { - success: true, - redirectUrl: portalSession.url - }; - } catch (error) { - console.error('Error creating portal session:', error); - return { - error: 'Failed to create portal session' - }; - } - } -}; - -// Note: Webhook handling should be moved to a separate API route -// Create /src/routes/api/webhook/+server.ts for webhook handling -// This is because webhooks need raw body access and special headers handling diff --git a/src/routes/app/account-settings/payment/add-subscription/+page.svelte b/src/routes/app/account-settings/payment/add-subscription/+page.svelte deleted file mode 100644 index c149fd23..00000000 --- a/src/routes/app/account-settings/payment/add-subscription/+page.svelte +++ /dev/null @@ -1,139 +0,0 @@ - - - - Add Subscription - CropWatch - - -
    -

    Add Subscription

    - - {#if form?.error} -
    - {form.error} -
    - {/if} - - {#if data.error} -
    - {data.error} -
    - {/if} - -
    - {#if data.subscriptionProducts.length === 0} -
    -

    No subscription products available at this time.

    -
    - {:else} -
    - {#each data.subscriptionProducts as product} -
    - {#if product.images.length > 0} - {product.name} - {/if} - -
    - - - - - - - - -
    -

    {product.name}

    - {#if product.description} -

    {product.description}

    - {/if} -
    -
    - - -
    - {#each product.prices as price} -
    -
    - - {formatPrice(price.unit_amount, price.currency)} - {formatBillingPeriod(price.recurring)} - - {#if price.nickname} - {price.nickname} - {/if} -
    - -
    { - return async ({ result }) => { - if ( - result.type === 'success' && - result.data && - 'redirectUrl' in result.data && - result.data.redirectUrl - ) { - // Redirect to Stripe Checkout - window.location.href = result.data.redirectUrl as string; - } - }; - }} - > - - -
    -
    - {/each} -
    -
    - {/each} -
    - {/if} -
    -
    diff --git a/src/routes/app/account-settings/payment/cancel/+page.svelte b/src/routes/app/account-settings/payment/cancel/+page.svelte deleted file mode 100644 index 8144a032..00000000 --- a/src/routes/app/account-settings/payment/cancel/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - Payment Cancelled - CropWatch - - -
    -
    -
    -

    Payment Cancelled

    -

    Your subscription setup was cancelled. No charges were made.

    -
    - - -
    -
    diff --git a/src/routes/app/account-settings/payment/success/+page.svelte b/src/routes/app/account-settings/payment/success/+page.svelte deleted file mode 100644 index 16cad8ad..00000000 --- a/src/routes/app/account-settings/payment/success/+page.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - - Payment Success - CropWatch - - -
    -
    -
    -

    Payment Successful!

    -

    Your subscription has been created successfully.

    -
    - - {#if sessionId} -
    -

    Manage Your Subscription

    -
    - - -
    -
    - {/if} - - - Back to Payment Settings - -
    -
    diff --git a/src/routes/app/all-devices/+page.server.ts b/src/routes/app/all-devices/+page.server.ts deleted file mode 100644 index a1d9b485..00000000 --- a/src/routes/app/all-devices/+page.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect, fail } from '@sveltejs/kit'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; -import type { PageServerLoad } from '../../$types'; - -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - throw redirect(302, '/auth/login'); - } - - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - const allDevicesNoPerm = await deviceService.getAllDevices(user.id); - - if (!allDevicesNoPerm) { - throw fail(500, { message: 'Could not fetch devices' }); - } - - // Double check the user only gets their own devices - const allDevicesPromise = allDevicesNoPerm.filter((d) => d.user_id && d.user_id === user.id); - - // If you still want `allDevices` in the page, return both: - return { allDevicesPromise }; -}; diff --git a/src/routes/app/all-devices/+page.svelte b/src/routes/app/all-devices/+page.svelte deleted file mode 100644 index ed414a9c..00000000 --- a/src/routes/app/all-devices/+page.svelte +++ /dev/null @@ -1,453 +0,0 @@ - - -
    -
    -
    -

    All Devices

    -

    - Manage and view all your monitoring locations -

    -
    -
    -
    -
    - - -
    -
    -
    -
    - - {#await allDevicesPromise} -
    -

    Loading devices...

    -
    - {:then devices} - {@const filteredDevices = filterDevices(devices)} - {#if filteredDevices.length > 0} - -
    - {#each filteredDevices as device, index (device.device_id || device.dev_eui || device.name || index)} -
    -
    -
    -

    - {device.name || device.dev_eui || 'Unnamed Device'} -

    -

    - ID: {device.device_id || device.dev_eui || 'N/A'} -

    -
    - {#if device.location_id && device.dev_eui} - - View - - {:else} - View - {/if} -
    -
    -
    - Location - {device.location_id ?? 'N/A'} -
    -
    - Type - {device.cw_device_type?.name ?? 'N/A'} -
    - {#if device.dev_eui} -
    - DevEUI - {device.dev_eui} -
    - {/if} -
    -
    - {/each} -
    - {:else} -
    - -

    - {searchTerm ? 'No devices match your search.' : 'No devices found.'} -

    -
    - {/if} - {:catch error} -
    -

    Error loading devices: {error.message}

    -
    - {/await} -
    - - diff --git a/src/routes/app/all-gateways/+page.svelte b/src/routes/app/all-gateways/+page.svelte deleted file mode 100644 index 3a5727c2..00000000 --- a/src/routes/app/all-gateways/+page.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -
    - -
    -
    -

    All Gateways

    -

    Watch the status of all your gateways

    -
    -
    -
    - -
    -
    -
    - -
    diff --git a/src/routes/app/all-notifications/+page.server.ts b/src/routes/app/all-notifications/+page.server.ts deleted file mode 100644 index 62c18393..00000000 --- a/src/routes/app/all-notifications/+page.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from '../../$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { RuleService } from '$lib/services/RuleService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; - -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - - if (!session || !user) { - throw redirect(302, '/auth/login'); - } - - const ruleRepository = new RuleRepository(supabase, new ErrorHandlingService()); - const deviceRepository = new DeviceRepository(supabase, new ErrorHandlingService()); - const ruleService = new RuleService(ruleRepository); - - const notifications = await ruleService.getRulesByProfile(user.id); - for (let notification of notifications) { - notification['cw_device'] = await deviceRepository.findById(notification.dev_eui); - } - - return { - notifications - }; -}; diff --git a/src/routes/app/all-notifications/+page.svelte b/src/routes/app/all-notifications/+page.svelte deleted file mode 100644 index cb7e0666..00000000 --- a/src/routes/app/all-notifications/+page.svelte +++ /dev/null @@ -1,865 +0,0 @@ - - -
    - -
    -
    -

    All Notifications

    -

    - Review every alert in a single professional table and monitor performance at a glance. -

    -
    -
    -
    - - -
    -
    -
    - - -
    -
    -

    Total notifications

    -

    {summaryStats.total}

    -
    -
    -

    Active right now

    -

    {summaryStats.active}

    -
    -
    -

    Total calls

    -

    {summaryStats.totalTriggers}

    -
    -
    - - -
    - {#if tableNotifications.length} - -
    - {#each tableNotifications as notification (notification.id)} -
    -
    -
    -

    - {notification.name || 'Untitled notification'} -

    - {#if notification.cw_device} - - {formatDeviceLabel(notification)} - - {:else} -

    - {formatDeviceLabel(notification)} -

    - {/if} -
    - - {notification.is_triggered ? 'Active' : 'Idle'} - -
    -
    -
    - Channel - {formatChannel(notification)} -
    -
    - Recipients - {formatRecipients(notification.action_recipient)} -
    -
    - Total calls - {notification.trigger_count ?? 0} -
    -
    - Last triggered - {formatLastTriggered(notification.last_triggered)} -
    -
    -
    - - {#if notification.cw_device} - - View device - - {/if} -
    -
    - {/each} -
    - {:else} -
    - -

    No notifications found

    -

    - {searchTerm - ? 'No notifications match your search. Try a different keyword.' - : 'Create a rule to start receiving notifications.'} -

    -
    - {/if} -
    -
    - -{#if showHistoryModal && selectedNotification} -
    - -
    -{/if} - - diff --git a/src/routes/app/all-reports/+page.server.ts b/src/routes/app/all-reports/+page.server.ts deleted file mode 100644 index 365f7d87..00000000 --- a/src/routes/app/all-reports/+page.server.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ReportTemplateRepository } from '$lib/repositories/ReportTemplateRepository'; -import { ReportTemplateService } from '$lib/services/ReportTemplateService'; -import { ReportService } from '$lib/services/ReportService'; -import { ReportAlertPointRepository } from '$lib/repositories/ReportAlertPointRepository'; -import { ReportRecipientRepository } from '$lib/repositories/ReportRecipientRepository'; -import { ReportUserScheduleRepository } from '$lib/repositories/ReportUserScheduleRepository'; -import { ReportRepository } from '$lib/repositories/ReportRepository'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; - -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - throw redirect(302, '/auth/login'); - } - - const reportRepository = new ReportRepository(supabase, new ErrorHandlingService()); - const reportTemplateRepository = new ReportTemplateRepository( - supabase, - new ErrorHandlingService() - ); - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const reportAlertPointRepository = new ReportAlertPointRepository( - supabase, - new ErrorHandlingService() - ); - const recipientRepository = new ReportRecipientRepository(supabase, new ErrorHandlingService()); - const reportUserScheduleRepository = new ReportUserScheduleRepository( - supabase, - new ErrorHandlingService() - ); - const deviceRepository = new DeviceRepository(supabase, new ErrorHandlingService()); - - const reportService = new ReportService( - reportRepository, - reportAlertPointRepository, - recipientRepository, - reportUserScheduleRepository, - deviceRepository - ); - const service = new ReportTemplateService(repo); - - const allReports = await reportService.getAllReportsWithDevices(user.id); - - return { allReports }; -}; - -export const actions: Actions = { - delete: async ({ request, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - return fail(401, { success: false, error: 'Unauthorized' }); - } - - const data = await request.formData(); - const id = Number(data.get('id')); - - if (!id) { - return fail(400, { success: false, error: 'Missing id' }); - } - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - await service.deleteReport(id); - return { success: true }; - } -}; diff --git a/src/routes/app/all-reports/+page.svelte b/src/routes/app/all-reports/+page.svelte deleted file mode 100644 index 7414f7f5..00000000 --- a/src/routes/app/all-reports/+page.svelte +++ /dev/null @@ -1,1123 +0,0 @@ - - -
    -
    -
    -

    {$_('all_reports_heading')}

    -

    - {$_('all_reports_subheading')} -

    -
    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - {$_('selection')} - -
    - - - - - - {$_('select_date_range')} - - {$_('bulk_download_description')} - - - - - - - - - - {#if bulkDownloading && bulkProgress} -

    - {$_('bulk_progress_message', { - values: { - current: bulkProgress.current, - total: bulkProgress.total - } - })} -

    - {/if} - - {#if filteredReports.length > 0} - -
    - {#each filteredReports as report (report.id)} - - {/each} -
    - {:else} -
    - -

    - {searchTerm ? $_('no_reports_match_search') : $_('no_reports_found')} -

    -
    - {/if} -
    - - diff --git a/src/routes/app/all-reports/[id]/+page.server.ts b/src/routes/app/all-reports/[id]/+page.server.ts deleted file mode 100644 index e0496947..00000000 --- a/src/routes/app/all-reports/[id]/+page.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ReportTemplateRepository } from '$lib/repositories/ReportTemplateRepository'; -import { ReportTemplateService } from '$lib/services/ReportTemplateService'; - -export const load: PageServerLoad = async ({ params, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - throw redirect(302, '/auth/login'); - } - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - const id = Number(params.id); - const report = await service.getReport(id); - - if (!report || report.owner_id !== user.id) { - throw redirect(302, '/app/all-reports'); - } - - return { report }; -}; diff --git a/src/routes/app/all-reports/[id]/+page.svelte b/src/routes/app/all-reports/[id]/+page.svelte deleted file mode 100644 index c396f498..00000000 --- a/src/routes/app/all-reports/[id]/+page.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -
    -

    {report.name}

    -
    {JSON.stringify(report.template, null, 2)}
    - Edit -
    diff --git a/src/routes/app/all-reports/[id]/edit/+page.server.ts b/src/routes/app/all-reports/[id]/edit/+page.server.ts deleted file mode 100644 index 7036ac7f..00000000 --- a/src/routes/app/all-reports/[id]/edit/+page.server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { PageServerLoad, Actions } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ReportTemplateRepository } from '$lib/repositories/ReportTemplateRepository'; -import { ReportTemplateService } from '$lib/services/ReportTemplateService'; - -export const load: PageServerLoad = async ({ params, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) throw redirect(302, '/auth/login'); - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - const id = Number(params.id); - const report = await service.getReport(id); - if (!report || report.owner_id !== user.id) throw redirect(302, '/app/all-reports'); - return { report }; -}; - -export const actions: Actions = { - update: async ({ request, params, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) return fail(401, { success: false }); - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - const id = Number(params.id); - const data = await request.formData(); - const name = data.get('name') as string; - const templateStr = data.get('template') as string; - const template = templateStr ? JSON.parse(templateStr) : {}; - await service.updateReport(id, { name, template }); - return { success: true }; - } -}; diff --git a/src/routes/app/all-reports/[id]/edit/+page.svelte b/src/routes/app/all-reports/[id]/edit/+page.svelte deleted file mode 100644 index c3fc77b6..00000000 --- a/src/routes/app/all-reports/[id]/edit/+page.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - -
    { - e.preventDefault(); - handleSubmit(e); - }} - class="flex flex-col gap-2 p-4" -> - - {#each points as p, i} -
    - - - - - -
    - {/each} - - - - - - -
    diff --git a/src/routes/app/all-reports/reports/create/+page.server.ts b/src/routes/app/all-reports/reports/create/+page.server.ts deleted file mode 100644 index 80ac82e6..00000000 --- a/src/routes/app/all-reports/reports/create/+page.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { ReportTemplateRepository } from '$lib/repositories/ReportTemplateRepository'; -import { ReportTemplateService } from '$lib/services/ReportTemplateService'; - -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - throw redirect(302, '/auth/login'); - } - return {}; -}; - -export const actions: Actions = { - createReport: async ({ request, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const { session, user } = await sessionService.getSafeSession(); - if (!session || !user) { - return fail(401, { success: false, error: 'Unauthorized' }); - } - - const formData = await request.formData(); - const name = formData.get('name') as string; - const templateStr = formData.get('template') as string; - const template = templateStr ? JSON.parse(templateStr) : {}; - - if (!name) { - return fail(400, { success: false, error: 'Name is required' }); - } - - const repo = new ReportTemplateRepository(supabase, new ErrorHandlingService()); - const service = new ReportTemplateService(repo); - - await service.createReport({ name, owner_id: user.id, template } as any); - - return { success: true }; - } -}; diff --git a/src/routes/app/all-reports/reports/create/+page.svelte b/src/routes/app/all-reports/reports/create/+page.svelte deleted file mode 100644 index 545e9fe7..00000000 --- a/src/routes/app/all-reports/reports/create/+page.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -
    -

    Create Report

    -
    { - e.preventDefault(); - handleSubmit(e); - }} - class="flex flex-col gap-2" - > - - {#each points as p, i} -
    - - - - - -
    - {/each} - - - - - - -
    -
    diff --git a/src/routes/app/all-reports/schedules/+page.server.ts b/src/routes/app/all-reports/schedules/+page.server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/app/dashboard/+page.server.ts b/src/routes/app/dashboard/+page.server.ts deleted file mode 100644 index 1f275559..00000000 --- a/src/routes/app/dashboard/+page.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; - -export const load: PageServerLoad = async ({ locals }) => { - // Create a new SessionService instance with the per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - // If no session exists, redirect to login - if (!sessionResult || !sessionResult.user) { - throw redirect(302, '/auth/login'); - } - - const { user } = sessionResult; - - // Return user data to the page - return { - user: { - id: user.id, - email: user.email, - name: user.user_metadata?.name || user.email?.split('@')[0] || 'User' - } - }; -}; \ No newline at end of file diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte deleted file mode 100644 index 8d10967e..00000000 --- a/src/routes/app/dashboard/+page.svelte +++ /dev/null @@ -1,675 +0,0 @@ - - - - IoT Dashboard - - -
    - {#if locationsStore.loadingLocations} -
    - - Loading Locations... -
    - {:else if locationsStore.locationError} -
    {locationsStore.locationError}
    - {:else} - - - -
    - - - - {#if locationsStore.loadingLocations} -
    Loading locations and devices...
    - {:else if locationsStore.locationError} -
    {locationsStore.locationError}
    - {:else if locationsStore.locations.length > 0} - {#if locationsStore.selectedLocationId !== null} - - {@const selectedLoc = locationsStore.locations.find( - (loc) => loc.location_id === locationsStore.selectedLocationId - )} - {#if selectedLoc} - - {:else} -
    Selected location not found.
    - {/if} - {:else} - - - {/if} - {:else} -

    No locations found.

    - {/if} -
    - - {/if} -
    - - diff --git a/src/routes/app/dashboard/dashboard.css b/src/routes/app/dashboard/dashboard.css deleted file mode 100644 index 462b9d15..00000000 --- a/src/routes/app/dashboard/dashboard.css +++ /dev/null @@ -1,200 +0,0 @@ -/* Base dashboard styles using CSS variables */ - -/* Panel containers - Base structure */ -.locations-panel { - background-color: var(--color-background); - color: var(--color-text); - padding: 1rem; - border-radius: 0.5rem; -} - -.location-list { - list-style: none; - padding: 0; - margin: 0; -} - -.location-list li { - margin: 0; - padding: 0; -} - -.location-item { - padding: 0.625rem; - margin-bottom: 0.375rem; - border-radius: 0.25rem; - cursor: pointer; - width: 100%; - text-align: left; - border: none; - background-color: transparent; - transition: background-color 0.2s ease; -} - -.location-item:hover { - background-color: var(--color-card); -} - -.location-item.selected { - background-color: var(--color-card); - font-weight: 700; -} - -.location-item:focus { - outline: none; - box-shadow: 0 0 0 2px var(--color-primary); -} - -.location-name { - display: block; - font-weight: 500; - color: var(--color-text); - opacity: 0.85; /* Better visibility than var(--color-text-secondary) */ -} - -.location-description { - display: block; - font-size: 0.875rem; - color: var(--color-text); - opacity: 0.85; /* Better visibility than var(--color-text-secondary) */ -} - -.location-device-count { - display: block; - font-size: 0.875rem; - color: var(--color-primary); - font-weight: 500; /* Make it slightly bolder for better visibility */ - margin-top: 0.125rem; -} - -.devices-panel { - color: var(--color-text); - background-color: #868585; - padding: 1rem; - border-radius: 0.5rem; -} - -.device-grid { - display: grid; - grid-template-columns: repeat(1, minmax(0, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -@media (min-width: 640px) { - .device-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (min-width: 1024px) { - .device-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - -.device-card { - background-color: var(--color-card); - padding: 1rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-sm); - transition: - background-color 0.2s ease, - box-shadow 0.2s ease; -} - -.device-card:hover { - box-shadow: var(--shadow-md); -} - -/* Sensor data sections */ -.sensor-data { - margin: 0.625rem 0; - padding: 0.5rem; - border-radius: 0.25rem; -} - -.air-data { - background-color: var(--color-air-bg); - border-left: 4px solid var(--color-air-border); -} - -.soil-data { - background-color: var(--color-soil-bg); - border-left: 4px solid var(--color-soil-border); -} - -.sensor-data h4 { - margin-top: 0; - margin-bottom: 0.25rem; - font-size: 0.875rem; -} - -.sensor-data ul { - margin: 0; - padding-left: 1.25rem; - font-size: 0.75rem; -} - -/* Status indicators */ -.loading, -.error { - text-align: center; - padding: 1.25rem; - font-size: 1.125rem; -} - -.loading-devices { - text-align: center; - padding: 1.25rem; - font-size: 1rem; - color: var(--color-text-secondary); -} - -.error { - color: var(--color-error); - background-color: var(--color-error-bg); - border-left: 4px solid var(--color-error); - margin: 0.625rem 0; -} - -/* Typography */ -h2, -h3 { - margin-top: 0; -} - -button { - background-color: var(--color-primary); - color: white; - border: none; - padding: 0.5rem 1rem; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 0.875rem; - border-radius: 0.25rem; - cursor: pointer; - transition: background-color 0.2s ease; - margin-top: 0.625rem; -} - -button:hover { - background-color: var(--color-primary-hover); -} - -/* Dashboard grid layout */ -.dashboard-grid { - display: grid; - grid-template-columns: 300px 1fr; - gap: 1.25rem; - margin-top: 1.25rem; -} - -.dashboard-container { - height: 100%; - width: 100%; - display: flex; - flex-direction: column; - margin: 0 auto; -} diff --git a/src/routes/app/dashboard/location/+page.server.ts b/src/routes/app/dashboard/location/+page.server.ts deleted file mode 100644 index 1b050e5f..00000000 --- a/src/routes/app/dashboard/location/+page.server.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; - -export interface LocationWithDevices { - location_id: number; - name: string; - description?: string | null; - lat?: number | null; - long?: number | null; - owner_id?: string | null; - created_at: string; - map_zoom?: number | null; - cw_devices?: DeviceBasic[]; -} - -export interface DeviceBasic { - dev_eui: string; - name: string; - device_type?: string | null; - lat?: number | null; - long?: number | null; - created_at: string; -} - -export const load = (async (event) => { - const { supabase, safeGetSession } = event.locals; - const { session, user } = await safeGetSession(); - - if (!session || !user) { - throw redirect(303, '/auth/signin'); - } - - try { - // Fetch all locations for the user - const { data: locations, error } = await supabase - .from('cw_locations') - .select( - ` - *, - cw_devices(*) - ` - ) - .eq('owner_id', user.id) - .order('name'); - - if (error) { - console.error('Error fetching locations:', error); - return { - locations: [] - }; - } - - const normalizedLocations: LocationWithDevices[] = (locations ?? []).map((loc: any) => ({ - location_id: loc.location_id, - name: loc.name, - description: loc.description, - lat: loc.lat, - long: loc.long, - owner_id: loc.owner_id, - created_at: loc.created_at, - map_zoom: loc.map_zoom, - cw_devices: Array.isArray(loc.cw_devices) - ? loc.cw_devices.map((device: any) => ({ - dev_eui: device.dev_eui, - name: device.name, - device_type: device.device_type ?? null, - lat: device.lat, - long: device.long, - created_at: device.created_at ?? '' - })) - : [] - })); - - return { - locations: normalizedLocations - }; - } catch (err) { - console.error('Error in load function:', err); - return { - locations: [] as LocationWithDevices[] - }; - } -}) satisfies PageServerLoad; diff --git a/src/routes/app/dashboard/location/+page.svelte b/src/routes/app/dashboard/location/+page.svelte deleted file mode 100644 index dfbdeae4..00000000 --- a/src/routes/app/dashboard/location/+page.svelte +++ /dev/null @@ -1,400 +0,0 @@ - - - - - All Locations | CropWatch Dashboard - - -
    - -
    -
    -

    All Locations

    -

    - Manage and view all your monitoring locations -

    -
    - - -
    - - -
    - -
    -
    - -
    -
    -

    Total Locations

    -

    - {data?.locations?.length || 0} -

    -
    -
    -
    - - -
    -
    - -
    -
    -

    Total Devices

    -

    - {(data?.locations || []).reduce( - (sum: number, loc: LocationWithDevices) => sum + (loc.cw_devices?.length || 0), - 0 - )} -

    -
    -
    -
    - - -
    -
    - -
    -
    -

    Active Locations

    -

    - {(locations || []).filter( - (loc: LocationWithDevices) => (loc.cw_devices?.length || 0) > 0 - ).length} -

    -
    -
    -
    -
    - - - -
    -
    -

    Locations Data Grid

    -
    - -
    - - -
    -
    - {processedData.length} locations • Click any row to view details -
    -
    -
    - - {#if locations.length > 0} -
    - -
    -
    - {#each columns as column} -
    - {#if column.sortable} - - {:else} - {column.label} - {/if} -
    - {/each} -
    -
    - - -
    - {#each locations as loc, index} -
    viewLocation(loc.location_id)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === 'Enter' && viewLocation(loc.location_id)} - > - -
    - - - {loc.name || 'Unnamed Location'} - -
    - - -
    - - {loc.description || 'No description'} - -
    - - -
    - {#if loc.lat && loc.long} -
    -
    {loc.lat.toFixed(4)}
    -
    {loc.long.toFixed(4)}
    -
    - {:else} - Not set - {/if} -
    - - -
    - 0 - ? 'text-green-600 dark:text-green-400' - : 'text-gray-400'} - size="1em" - /> - 0 - ? 'font-semibold text-green-600 dark:text-green-400' - : 'text-gray-400'} - > - {loc.deviceCount} - -
    - - -
    - - {new Date(loc.created_at).toLocaleDateString()} - -
    - - -
    - - -
    -
    - {/each} -
    -
    - {:else if searchTerm.trim()} - -
    - -

    No locations found

    -

    No locations match your search for "{searchTerm}"

    - -
    - {:else} - -
    - -

    No locations found

    -

    Get started by creating your first location

    - -
    - {/if} -
    -
    -
    - - diff --git a/src/routes/app/dashboard/location/+page.ts b/src/routes/app/dashboard/location/+page.ts deleted file mode 100644 index 9ab4dd9e..00000000 --- a/src/routes/app/dashboard/location/+page.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Disable SSR for RevoGrid compatibility -export const ssr = false; diff --git a/src/routes/app/dashboard/location/[location_id]/+layout.server.ts b/src/routes/app/dashboard/location/[location_id]/+layout.server.ts deleted file mode 100644 index a779fc05..00000000 --- a/src/routes/app/dashboard/location/[location_id]/+layout.server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { LocationService } from '$lib/services/LocationService'; -import { SessionService } from '$lib/services/SessionService'; -import { error, redirect } from '@sveltejs/kit'; -import type { LayoutServerLoad } from './$types'; - -/** - * Load `location` and `locationId` for all pages under this layout. - */ -export const load = (async ({ params, locals: { supabase } }) => { - const sessionService = new SessionService(supabase); - const sessionResult = await sessionService.getSafeSession(); - - // If no session exists, redirect to login - if (!sessionResult || !sessionResult.user) { - throw redirect(302, '/auth/login'); - } - - const locationId = parseInt(params.location_id, 10); - - if (!locationId) { - throw error(400, 'Invalid location ID'); - } - - try { - // Create services directly - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationRepo = new LocationRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - const location = await locationService.getLocationById(locationId); - - return { - ownerId: sessionResult?.user?.id, - location, - locationId - }; - } catch (err) { - console.error(`Error loading location ${locationId} details:`, err); - throw error(500, 'Failed to load location details'); - } -}) satisfies LayoutServerLoad; diff --git a/src/routes/app/dashboard/location/[location_id]/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/+page.server.ts deleted file mode 100644 index e17bee7e..00000000 --- a/src/routes/app/dashboard/location/[location_id]/+page.server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; - -/** - * Load `devices` for a specific location. - * User session and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ params, locals }) => { - const locationId = parseInt(params.location_id, 10); - - try { - // Create error handler and dependencies - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - - // Fetch devices for this location - // Don’t `await` the promise, stream the data instead - const devices = deviceService.getDevicesByLocation(locationId); - - return { - devices // streamed - }; - } catch (err) { - console.error(`Error loading location ${locationId} details:`, err); - throw error(500, 'Failed to load location details'); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/+page.svelte deleted file mode 100644 index 78eb748d..00000000 --- a/src/routes/app/dashboard/location/[location_id]/+page.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - - - Location: {location.name} | CropWatch - - -
    - - -
    - -
    - -
    - -
    -
    - - -
    - - {#if clickedCoords} -
    -

    - Clicked Coordinates: - {clickedCoords.lat.toFixed(6)}, {clickedCoords.lon.toFixed(6)} -

    -
    - {/if} -
    - -
    -

    {$_('Location Details')}

    -
    -
    - {$_('Location ID')}: - {location.location_id} -
    -
    - {$_('Created')}: - {new Date(location.created_at).toLocaleDateString($locale ?? undefined, { - year: 'numeric', - month: 'long', - day: 'numeric' - })} -
    -
    - {$_('Coordinates')}: - {location.lat?.toFixed(6)}, {location.long?.toFixed(6)} -
    -
    -
    -
    - -
    - -
    -

    {$_('Devices')}

    - {#await data.devices} - Loading devices... - {:then devices} - {#if devices.length > 0} -
    - {#each devices as device} -
    -
    -

    - - {device.name} - -

    -

    - EUI: - {device.dev_eui} -

    -
    -
    - -
    -
    - {/each} -
    - {:else} -

    {$_('No devices found for this location.')}

    - {/if} - {/await} -
    -
    -
    - - diff --git a/src/routes/app/dashboard/location/[location_id]/Header.svelte b/src/routes/app/dashboard/location/[location_id]/Header.svelte deleted file mode 100644 index f3580484..00000000 --- a/src/routes/app/dashboard/location/[location_id]/Header.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -
    -
    -

    - {location.name} -

    - {#if location.description} -

    {location.description}

    - {/if} -
    -
    - {#if children} - {@render children?.()} - {:else if basePath} - - {/if} -
    -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/+page.server.ts deleted file mode 100644 index cc0ab33f..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/+page.server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceService } from '$lib/services/DeviceService'; - -/** - * Load `devices` for a specific location. - * User session and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ params, locals }) => { - const locationId = parseInt(params.location_id, 10); - - try { - // Get the error handler - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepo); - - // Fetch devices for this location - // Don’t `await` the promise, stream the data instead - const devices = deviceService.getDevicesByLocation(locationId); - - return { - devices // streamed - }; - } catch (err) { - console.error(`Error loading location ${locationId} details:`, err); - throw error(500, 'Failed to load location details'); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/+page.svelte deleted file mode 100644 index 557a7f4e..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/+page.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - - Devices | CropWatch - - -
    -

    Devices

    - - Add Device - -
    - -{#await data.devices} -
    Loading devices...
    -{:then devices} - {#if devices?.length} -
    - {#each devices as device} -
    -

    {device.name}

    -

    EUI: {device.dev_eui}

    - {#if device.lat != null && device.long != null} -

    - Coords: - {device.lat.toFixed(4)}, {device.long.toFixed(4)} -

    - {/if} -
    - - View - - - Settings - -
    -
    - {/each} -
    - {:else} -
    No devices found for this location.
    - {/if} -{/await} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+error.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+error.svelte deleted file mode 100644 index 5595ff54..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+error.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - - {$_('Device Error')} | CropWatch - - - -
    -
    -
    -
    - - - -
    -

    {$_('Device Error')}

    -

    - {$_( - 'There was an error loading the device. Please try again or contact our support team for assistance.' - )} -

    -
    - - -
    - -
    -
    -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+layout.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+layout.server.ts deleted file mode 100644 index 924f61ef..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+layout.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { LayoutServerLoad } from './$types'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; - -/** - * Load device details for a specific location and device EUI. - * User session and `locationId` are already validated in the layout server load. - */ -export const load = (async ({ params, locals: { supabase } }) => { - const { devEui } = params; - - if (!devEui) { - throw new Error('Missing required parameters: location_id or devEui'); - } - - // Get error handler from container - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - - const device = await deviceService.getDeviceWithTypeByEui(devEui); - - return { - devEui, - device, - dataType: device?.cw_device_type?.data_table_v2 as string - }; -}) satisfies LayoutServerLoad; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts deleted file mode 100644 index 83eae990..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.server.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { DateTime } from 'luxon'; - -/** - * Load the latest and historical device data for a specific device EUI. - * User session, `devEui` and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ url, params, parent, locals: { supabase } }) => { - const { devEui } = params; - - try { - const parentData = await parent(); - const dataTable = parentData?.dataType; - const deviceDataService = new DeviceDataService(supabase); - - const startParam = url.searchParams.get('start'); - const endParam = url.searchParams.get('end'); - const inferredTimezone = dataTable === 'cw_traffic2' ? 'Asia/Tokyo' : 'UTC'; - const timezoneParam = url.searchParams.get('timezone') || inferredTimezone; - let startDate: Date; - let endDate: Date; - const nowInZone = DateTime.now().setZone(timezoneParam); - const now = nowInZone.isValid ? nowInZone : DateTime.now(); - - if (!startParam || !endParam) { - if (dataTable === 'cw_traffic2') { - startDate = now.startOf('month').startOf('day').toJSDate(); - endDate = now.endOf('day').toJSDate(); - } else { - startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - endDate = now.endOf('day').toJSDate(); - } - } else { - const startInZone = DateTime.fromISO(startParam, { zone: timezoneParam }); - const endInZone = DateTime.fromISO(endParam, { zone: timezoneParam }); - startDate = (startInZone.isValid ? startInZone : DateTime.fromISO(startParam)) - .startOf('day') - .toJSDate(); - endDate = (endInZone.isValid ? endInZone : DateTime.fromISO(endParam)) - .endOf('day') - .toJSDate(); - } - - // Don’t `await` the promise, stream the data instead - const latestData = deviceDataService.getLatestDeviceData(devEui); - const historicalData = deviceDataService.getDeviceDataByDateRange( - devEui, - startDate, - endDate, - timezoneParam - ); - - return { - latestData, // streamed - historicalData // streamed - }; - } catch (err) { - console.error(`Error loading device details for ${devEui}:`, err); - throw error(500, 'Failed to load device details'); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte deleted file mode 100644 index 32714b83..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ /dev/null @@ -1,737 +0,0 @@ - - - - Device Details - {device?.name || device?.dev_eui} - - -
    - {#if device.battery_level !== null} - - {/if} - - - -
    - {#if (numericKeys.length && device.user_id == userId) || (devicePermissionLevel !== null && devicePermissionLevel <= 2)} - - {/if} - - {#if device.user_id == userId || devicePermissionLevel === 1} - - {/if} -
    -
    - - -
    -
    - - {#if device.cw_device_type?.data_table_v2 !== 'cw_relay_data'} -
    - -
    -

    {$_('Latest Sensor Readings')}

    - {#if latestData} -
    -
    - {#each Object.keys(latestData) as key (key)} - {#if !['id', 'dev_eui', 'created_at', 'is_simulated', 'battery_level', 'vape_detected', 'smoke_detected', 'traffic_hour'].includes(key) && latestData[key] !== null} - - {/if} - {/each} -
    - -

    - {$_('Last updated:')} - {formatDateForDisplay(latestData.created_at)} -

    -
    - {:else} -

    {$_('No recent data available')}

    - {/if} -
    - - -
    - {/if} - - -
    - {#if loadingHistoricalData} -
    - - {$_('Loading historical data...')} -
    - {/if} -
    - -
    -
    - {#if loading} -
    - -

    {$_('Loading historical data...')}

    -
    - {:else if historicalData.length === 0 && device.cw_device_type?.data_table_v2 !== 'cw_relay_data'} -
    - {$_('No historical data available for the selected date range.')} -
    - {:else if device.cw_device_type?.data_table_v2 === 'cw_relay_data'} - - {:else} -
    -

    {$_('Stats Summary')}

    - - -
    - {#each numericKeys as key (key)} - {#if stats[key]} - - {/if} - {/each} -
    -
    - {/if} -
    - - -
    - - -
    -
    -
    -
    - - -{#if !loading && !loadingHistoricalData && historicalData.length > 0} -
    -

    {$_('Data Chart')}

    -
    - {#if renderingVisualization} -
    Rendering chart...
    - {/if} -
    -
    - {#each numericKeys as key, index (key)} -
    -

    {$_(key)}

    -
    -
    -
    - {/each} -
    -
    -
    -
    -{/if} - - -
    - {#if device.cw_device_type?.data_table_v2 === 'cw_air_data'} - - {:else if ['traffic_v2', 'cw_traffic2'].includes(device.cw_device_type?.data_table_v2 ?? '')} -

    {$_('Weather & Data')}

    - - {:else} - - {/if} -
    - - diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/Header.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/Header.svelte deleted file mode 100644 index 8b998640..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/Header.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
    -

    -
    - - {device.name} -
    -

    - {#if children} - {@render children?.()} - {:else if basePath} - - {/if} -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/control/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/control/+page.svelte deleted file mode 100644 index 9e12b54f..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/control/+page.svelte +++ /dev/null @@ -1,313 +0,0 @@ - - - -
    -
    -

    City Infrastructure Control System

    -
    - - -
    -
    - -
    -
    -

    City Map

    -
    - - - - - - - - - - - - - - - - - - {#each buildings as building (building.id)} - selectBuilding(building)} class="cursor-pointer" role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && selectBuilding(building)}> - - - {building.name} - - - {/each} - -
    - -
    -

    Status Legend

    -
    - {#if viewMode === 'lights'} -
    -
    - All lights on -
    -
    -
    - Some lights on -
    -
    -
    - All lights off -
    - {:else} -
    -
    - All doors locked -
    -
    -
    - Some doors locked -
    -
    -
    - All doors unlocked -
    - {/if} -
    -
    -
    - -
    - {#if selectedBuilding} -

    {selectedBuilding.name} - {viewMode === 'lights' ? 'Lights' : 'Door Locks'}

    - -
    - {#each viewMode === 'lights' ? selectedBuilding.lights : selectedBuilding.locks as device (device.id)} -
    -
    -

    {device.name}

    - - {#if viewMode === 'lights'} - {device.status === 'on' ? '💡 ON' : '🌙 OFF'} - {:else} - {device.status === 'locked' ? '🔒 LOCKED' : '🔓 UNLOCKED'} - {/if} - -
    - -
    - {/each} -
    - -
    -

    Building Summary

    - {#if viewMode === 'lights'} -

    - {selectedBuilding.lights.filter((l: Device) => l.status === 'on').length} of {selectedBuilding.lights.length} lights are on -

    - {:else} -

    - {selectedBuilding.locks.filter((l: Device) => l.status === 'locked').length} of {selectedBuilding.locks.length} doors are locked -

    - {/if} -
    - {:else} -
    -

    Select a Building

    -

    Click on a building marker on the map to view and control its {viewMode}.

    - -
    -

    Available Buildings:

    - {#each buildings as building (building.id)} - - {/each} -
    -
    - {/if} -
    -
    -
    \ No newline at end of file diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.css b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.css deleted file mode 100644 index b2940483..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.css +++ /dev/null @@ -1,292 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Keep your CSS variables */ -:root { - /* Base colors */ - --color-primary: #4CAF50; - /* ... other variables ... */ -} - -.device-details-container { - max-width: 1200px; - margin: 0 auto; - padding: 1rem; -} - -.device-header { - margin-bottom: 2rem; -} - -.back-link { - margin-bottom: 1rem; -} - -.back-link a { - color: #0077cc; - text-decoration: none; -} - -.device-meta { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-top: 1rem; - background-color: #f5f5f5; - padding: 1rem; - border-radius: 4px; -} - -.device-meta p { - margin: 0.25rem 0; -} - -.device-meta span { - font-weight: 500; -} - -.data-section { - margin-bottom: 3rem; - padding: 1.5rem; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.timestamp { - text-align: right; - color: #777; - font-size: 0.9rem; - margin-top: 1rem; -} - -.date-range-selector { - margin: 1.5rem 0; - padding: 1rem; - border-radius: 4px; -} - -.date-inputs { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: flex-end; -} - -.form-group { - display: flex; - flex-direction: column; -} - -.form-group label { - margin-bottom: 0.3rem; - font-size: 0.9rem; -} - -.form-group input { - padding: 0.5rem; - border: 1px solid #ccc; - border-radius: 4px; -} - -.fetch-btn { - padding: 0.5rem 1rem; - background-color: #0077cc; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; -} - -.fetch-btn:hover { - background-color: #005fa3; -} - -.fetch-btn:disabled { - background-color: #cccccc; - cursor: not-allowed; -} - -.error-message { - color: #e74c3c; - margin-top: 1rem; -} - -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - padding: 2rem; -} - -.stats-section { - margin: 2rem 0; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.charts-section { - margin: 2rem 0; -} - -.chart-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin-top: 1rem; -} - -.chart { - padding: 1rem; - background-color: #f9f9f9; - border-radius: 6px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.chart h4 { - margin-top: 0; - margin-bottom: 1rem; - text-align: center; -} - -.chart-placeholder { - height: 300px; - position: relative; - padding-top: 20px; -} - -.chart-legend { - display: flex; - justify-content: space-between; - margin-bottom: 10px; - font-size: 0.8rem; -} - -.chart-visual { - height: 400px; - border-bottom: 1px solid #ddd; - border-left: 1px solid #ddd; - position: relative; - display: flex; - flex-direction: column; -} - -.chart-visual .chart:first-child { - height: 350px; - margin-bottom: 10px; -} - -.chart-visual .chart:last-child { - height: 150px; -} - -/* ApexCharts styling overrides */ -.apexcharts-canvas { - background-color: transparent !important; -} - -.apexcharts-tooltip { - box-shadow: 0 2px 10px rgba(0,0,0,0.2) !important; - border: none !important; -} - -.apexcharts-yaxis-label, -.apexcharts-xaxis-label { - font-size: 12px !important; -} - -/* Make sure the chart doesn't overflow on mobile */ -@media (max-width: 768px) { - .chart-placeholder { - height: 450px; - } - - .chart-visual { - height: 450px; - } - - .chart-visual .chart:first-child { - height: 300px; - } - - .chart-visual .chart:last-child { - height: 120px; - } -} - -.chart-bars { - display: flex; - align-items: flex-end; - height: 100%; - padding-top: 10px; - justify-content: space-around; -} - -.chart-bar { - width: 10px; - background-color: #0077cc; - margin: 0 2px; - min-height: 1px; -} - -.humidity-bar { - background-color: #4ecdc4; -} - -.moisture-bar { - background-color: #45b7d8; -} - -.time-axis { - text-align: center; - margin-top: 5px; - font-size: 0.8rem; - color: #666; -} - -.data-table-section { - margin: 2rem 0; - overflow-x: auto; -} - -.table-container { - margin-top: 1rem; - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -th, -td { - padding: 0.75rem 1rem; - border-bottom: 1px solid #ddd; -} - -th { - background-color: #f5f5f5; - font-weight: 500; -} - -tr:nth-child(even) { - background-color: #f9f9f9; -} - -.no-data { - text-align: center; - padding: 2rem; - color: #777; - font-style: italic; -} - -.bg-foreground-light { background-color: var(--color-foreground-light); } -.bg-foreground-dark { background-color: var(--color-foreground-dark); } \ No newline at end of file diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts deleted file mode 100644 index 1f1287d9..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { browser } from '$app/environment'; -import type { DeviceWithType } from '$lib/models/Device'; -import type { DeviceDataRecord, DeviceStats } from '$lib/models/DeviceDataRecord'; -import { calculateAverage, formatDateForDisplay, hasValue } from '$lib/utilities/helpers'; -import { getNumericKeys, getTextColorByKey } from '$lib/utilities/stats'; -import { DateTime } from 'luxon'; -import { _ } from 'svelte-i18n'; -import { get } from 'svelte/store'; - -export interface DeviceDetailProps { - user: any; - device: DeviceWithType; - dataType: string; - latestData: any; - historicalData: DeviceDataRecord[]; -} - -export function setupDeviceDetail() { - // Stats for the data - const stats: DeviceStats = $state({}); - - // Chart data - type ChartData = Record; - let chartData: ChartData = $state({ labels: [] }); - - // States for the component - let loading = $state(false); - let error = $state(null); - - // Date range selection - initialize in LOCAL time (today end-of-day and 24h before) - const __endInit = new Date(); - __endInit.setHours(23, 59, 59, 999); - let endDate = $state(__endInit); - let startDate = $state(new Date(__endInit.getTime() - 24 * 60 * 60 * 1000)); - - // Libraries and elements - let ApexCharts = $state(undefined); - - // Chart instances - const mainChartInstances: Record = {}; - const brushChartInstances: Record = {}; - - // Function to process historical data and calculate stats - function processHistoricalData(historicalData: any[]) { - if (!historicalData || historicalData.length === 0) return; - - // Placeholder for the chart data: Don’t update the `chartData` state directly in the loops below - // because the UI will be unresponsive due to repeated reactivity updates by Svelte. - const _chartData: ChartData = { labels: [] }; - - // Extract timestamps for chart labels (most recent to oldest) - _chartData.labels = historicalData - .map((data) => formatDateForDisplay(data.created_at)) - .reverse(); - - // Determine numeric keys dynamically (excluding dev_eui, created_at) - const numericKeys = getNumericKeys(historicalData); - - // Reset chartData and stats for all numeric keys - numericKeys.forEach((key) => { - _chartData[key] = []; - stats[key] = { - min: 0, - max: 0, - avg: 0, - median: 0, - stdDev: 0, - count: 0, - lastReading: 0, - trend: null - }; - }); - - // Prepare value arrays for stats - const valueArrays: Record = {}; - numericKeys.forEach((key) => { - valueArrays[key] = []; - }); - - // Fill chartData and value arrays - historicalData.forEach((data) => { - numericKeys.forEach((key) => { - if (typeof data[key] === 'number' && data[key] !== null) { - (_chartData[key] as number[]).unshift(data[key]); - valueArrays[key].push(data[key]); - } - }); - }); - - // Calculate stats for each key - numericKeys.forEach((key) => { - const values = valueArrays[key]; - if (values.length > 0) { - // Basic stats - stats[key].min = Math.min(...values); - stats[key].max = Math.max(...values); - stats[key].avg = calculateAverage(values); - stats[key].count = values.length; - - // Last reading - stats[key].lastReading = values[values.length - 1]; - - // Median calculation - const sortedValues = [...values].sort((a, b) => a - b); - const mid = Math.floor(sortedValues.length / 2); - stats[key].median = - sortedValues.length % 2 === 0 - ? (sortedValues[mid - 1] + sortedValues[mid]) / 2 - : sortedValues[mid]; - - // Standard deviation - const mean = stats[key].avg; - const squaredDiffs = values.map((value) => Math.pow(value - mean, 2)); - const avgSquaredDiff = calculateAverage(squaredDiffs); - stats[key].stdDev = Math.sqrt(avgSquaredDiff); - - // Trend (last 5 values if available) - if (values.length >= 3) { - const recentValues = values.slice(-5); - const firstVal = recentValues[0]; - const lastVal = recentValues[recentValues.length - 1]; - const difference = lastVal - firstVal; - const threshold = stats[key].stdDev * 0.1; // 10% of standard deviation as threshold - - if (Math.abs(difference) < threshold) { - stats[key].trend = 'stable'; - } else if (difference > 0) { - stats[key].trend = 'up'; - } else { - stats[key].trend = 'down'; - } - } else { - stats[key].trend = null; - } - } - }); - - chartData = _chartData; - loading = false; - } - - // Function to fetch data for a specific date range - async function fetchDataForDateRange(device: DeviceWithType, start: Date, end: Date) { - // Parameters 'start' and 'end' are Date objects. - // The component's state variables 'startDate' and 'endDate' (if you were accessing them via this context, which you are not directly here) are also Date objects. - // This function uses the 'start' and 'end' parameters passed to it. - - if (!start || !end) { - // Validation using parameters - error = 'Please select both start and end dates'; // Set component's error state - return []; - } - - if (start > end) { - // Validation using parameters - error = 'Start date must be before end date'; // Set component's error state - return []; - } - - loading = true; - error = null; // Clear previous errors - - try { - // Normalize the dates to calendar-day strings in the user's timezone for the API - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - const startQueryParam = DateTime.fromJSDate(start).setZone(timezone).toISODate(); - const endQueryParam = DateTime.fromJSDate(end).setZone(timezone).toISODate(); - if (!startQueryParam || !endQueryParam) { - throw new Error('Unable to format selected dates for the request.'); - } - - const searchParams = new URLSearchParams({ - start: startQueryParam, - end: endQueryParam, - timezone - }); - - const response = await fetch( - `/api/devices/${device.dev_eui}/data?${searchParams.toString()}` - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Failed to fetch data:', response.status, errorText); - throw new Error(`Failed to fetch data. Server responded with ${response.status}.`); - } - - const newHistoricalData = await response.json(); - if (!Array.isArray(newHistoricalData)) { - console.error('API did not return an array for historical data:', newHistoricalData); - throw new Error('Invalid data format received from server.'); - } - processHistoricalData(newHistoricalData); // Process the newly fetched data - return newHistoricalData; - } catch (err) { - console.error('Error in fetchDataForDateRange:', err); - error = err instanceof Error ? err.message : 'Unknown error occurred while fetching data.'; - return []; // Return empty array on error - } finally { - loading = false; - } - } - - /** - * Renders a generic ApexCharts line chart with a brush (range selector) below. This function is - * fully generic: it will plot all numeric keys in the data (except for ignored keys). - * @param params - * @param params.historicalData Array of objects (rows) with at least a 'created_at' timestamp and - * numeric fields - * @param params.chart1Element HTMLElement to render the main chart into - * @param params.chart1BrushElement HTMLElement to render the brush chart into - * @param params.key Optional key to render a specific chart (if not provided, all numeric keys - * will be rendered) - * @param params.ignoredDataKeys Array of keys to ignore (default: ['id', 'dev_eui', - * 'created_at']) - */ - async function renderVisualization({ - historicalData, - chart1Element, - chart1BrushElement, - key = '_all', - ignoredDataKeys = ['id', 'dev_eui', 'created_at'] - }: { - historicalData: DeviceDataRecord[]; - chart1Element?: HTMLElement; - chart1BrushElement?: HTMLElement; - key?: string; - ignoredDataKeys?: string[]; - }) { - if (!browser || !historicalData || historicalData.length === 0) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - if (!chart1Element || !chart1BrushElement) return; - - // Destroy previous chart instances if they exist - if (mainChartInstances[key]) { - try { - mainChartInstances[key].destroy(); - } catch {} - mainChartInstances[key] = null; - } - if (brushChartInstances[key]) { - try { - brushChartInstances[key].destroy(); - } catch {} - brushChartInstances[key] = null; - } - - if (!ApexCharts) { - ApexCharts = await import('apexcharts').then((m) => m.default); - } - - // Find all numeric keys in the data (excluding ignored keys) - const numericKeys = getNumericKeys(historicalData, ignoredDataKeys); - if (numericKeys.length === 0) return; - - let keys = [...numericKeys]; - - if (key !== '_all') { - // Filter numeric keys based on the provided key - if (numericKeys.includes(key)) { - keys = [key]; - } else { - return; - } - } - - // Build series for each numeric key - const series = keys.map((key) => ({ - name: get(_)(key), - data: historicalData - .filter((row) => typeof row[key] === 'number' && row[key] !== null && row['created_at']) - .map((row) => ({ x: new Date(row['created_at']).getTime(), y: row[key] })) - })); - series.forEach((s) => s.data.sort((a, b) => a.x - b.x)); - - const colorMap = Object.fromEntries(keys.map((key) => [key, getTextColorByKey(key)])); - const colors = Object.values(colorMap); - - // Y-axis config for each series - const yaxis = keys.map((key, idx) => ({ - seriesName: key, - opposite: idx % 2 === 1, - title: - key === '_all' - ? { text: get(_)(key), style: { fontSize: '16px', color: colorMap[key] } } - : {}, - labels: { style: { colors: colorMap[key] } } - })); - - // X-axis range - const allDates = historicalData - .map((row) => new Date(row['created_at']).getTime()) - .filter(Boolean); - const minDate = Math.min(...allDates); - const maxDate = Math.max(...allDates); - - // Main chart options - const mainChartOptions = { - series, - chart: { - id: `chart-${key}-main`, - type: 'line', - height: 200, - toolbar: { autoSelected: 'pan', show: false }, - animations: { enabled: false }, - zoom: { enabled: false } - }, - colors, - stroke: { curve: 'smooth', width: keys.map(() => 1) }, - dataLabels: { enabled: false }, - markers: { size: 1, strokeWidth: 0, hover: { size: 4 } }, - xaxis: { type: 'datetime', labels: { datetimeUTC: false } }, - yaxis, - tooltip: { - theme: 'dark', - shared: true, - x: { format: 'MMM dd HH:mm' }, - style: { fontSize: '13px', 'font-family': 'Inter, sans-serif' }, - marker: { show: true }, - y: { formatter: (val: number) => (val?.toFixed ? val.toFixed(1) : val) } - }, - legend: { position: 'top', horizontalAlign: 'center' }, - grid: { - borderColor: '#2a2a2a', - row: { colors: ['transparent', 'transparent'], opacity: 0.1 } - } - }; - - // Brush chart options - const brushChartOptions = { - series, - chart: { - id: `chart-${key}-brush`, - height: 100, - type: 'area', - brush: { target: `chart-${key}-main`, enabled: true }, - selection: { enabled: true, xaxis: { min: minDate, max: maxDate } } - }, - colors, - fill: { type: 'gradient', gradient: { opacityFrom: 0.7, opacityTo: 0.3 } }, - stroke: { width: keys.map(() => 1) }, - xaxis: { type: 'datetime', tooltip: { enabled: false }, labels: { datetimeUTC: false } }, - yaxis: { show: false, tickAmount: 2 }, - grid: { borderColor: '#2a2a2a', strokeDashArray: 2, yaxis: { lines: { show: false } } }, - legend: { show: false } - }; - - // Render charts - mainChartInstances[key] = new ApexCharts(chart1Element, mainChartOptions); - brushChartInstances[key] = new ApexCharts(chart1BrushElement, brushChartOptions); - try { - await mainChartInstances[key].render(); - await brushChartInstances[key].render(); - } catch (err) { - if (mainChartInstances[key]) { - try { - mainChartInstances[key].destroy(); - } catch {} - mainChartInstances[key] = null; - } - if (brushChartInstances[key]) { - try { - brushChartInstances[key].destroy(); - } catch {} - brushChartInstances[key] = null; - } - } - } - - // Initialize dates function - function initializeDateRange() { - const end = new Date(); - end.setHours(23, 59, 59, 999); - endDate = end; - startDate = new Date(end.getTime() - 24 * 60 * 60 * 1000); - } - - return { - // State - stats, - get chartData() { - return chartData; - }, - get loading() { - return loading; - }, - set loading(v: boolean) { - loading = v; - }, - get error() { - return error; - }, - set error(v: string | null) { - error = v; - }, - get startDate() { - return startDate; - }, - set startDate(v: Date) { - startDate = v; - }, - get endDate() { - return endDate; - }, - set endDate(v: Date) { - endDate = v; - }, - - // Element refs - // Functions - formatDateForDisplay, - hasValue, - processHistoricalData, - fetchDataForDateRange, - renderVisualization, - initializeDateRange // Exporting the function - }; -} - -// Derived properties calculations -export function getDeviceDetailDerived(device: DeviceWithType, dataType: string, latestData: any) { - // Determine device type name - const deviceTypeName = device?.cw_device_type?.name || 'Unknown Type'; - - // Reactive declarations for the charts - const temperatureChartVisible = dataType === 'cw_air_data' || dataType === 'cw_soil_data'; - const humidityChartVisible = dataType === 'cw_air_data'; - const moistureChartVisible = dataType === 'cw_soil_data'; - const co2ChartVisible = dataType === 'cw_air_data' && hasValue(latestData, 'co2'); - const phChartVisible = dataType === 'cw_soil_data' && hasValue(latestData, 'ph'); - - // Helper function to determine if a property exists and has a value - function hasValue(obj: any, prop: string): boolean { - return obj && prop in obj && obj[prop] !== null; - } - - return { - deviceTypeName, - temperatureChartVisible, - humidityChartVisible, - moistureChartVisible, - co2ChartVisible, - phChartVisible - }; -} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/downlink/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/downlink/+page.svelte deleted file mode 100644 index 443397c2..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/downlink/+page.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -

    Device Downlink Control: {devEui}

    -
    - - - - -
    - -
    {JSON.stringify(status, null, 2)}
    - - diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts deleted file mode 100644 index e2fb1d74..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/realtime.svelte.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { browser } from '$app/environment'; -import type { - RealtimeChannel, - RealtimePostgresInsertPayload, - SupabaseClient -} from '@supabase/supabase-js'; - -let channel: RealtimeChannel | null = $state(null); - -export function setupRealtimeSubscription( - supabase: SupabaseClient, - deviceDataTable: string, - devEui: string, - onDataUpdate: (newData: any) => void, - retryCount = 0 -) { - if (!browser) return; - - const refreshAggregatedTraffic = async () => { - try { - const response = await fetch(`/api/devices/${devEui}/status`); - if (!response.ok) { - console.warn( - `[DeviceRealtime] Failed to refresh aggregated traffic data for ${devEui}`, - response.status - ); - return; - } - const aggregated = await response.json(); - onDataUpdate(aggregated); - } catch (error) { - console.error('[DeviceRealtime] Error refreshing aggregated traffic data', error); - } - }; - - console.log('🔄 Setting up real-time subscription...'); - channel = supabase - .channel(`${devEui}-changes`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: deviceDataTable, - filter: `dev_eui=eq.${devEui}` - }, - (payload) => { - // Handle real-time updates for users - if (payload.eventType === 'UPDATE' || payload.eventType === 'INSERT') { - console.log('📡 Real-time data received:', payload.new); - if (deviceDataTable === 'cw_traffic2') { - void refreshAggregatedTraffic(); - } else { - onDataUpdate(payload.new); - } - } - } - ) - .subscribe(); - return channel; -} - -export function removeRealtimeSubscription(supabase: SupabaseClient) { - if (channel) { - console.log('🔄 Removing real-time subscription...'); - supabase.removeChannel(channel); - channel.unsubscribe(); - channel = null; - } else { - console.warn('No active real-time channel to remove.'); - } -} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+page.svelte deleted file mode 100644 index 0dbb3486..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+page.svelte +++ /dev/null @@ -1,704 +0,0 @@ - - -
    -

    PDF Generator

    - -
    -
    - How These PDF Generation Methods Work
    -

    - Method 1 (jsPDF with Text): The frontend requests HTML content from the - backend, then uses jsPDF's text() method to add Japanese text directly - to the PDF. This creates selectable text but requires manual layout. -

    -

    - Method 2 (PDFKit - Server): The server generates a complete PDF using - PDFKit with proper Japanese font embedding. This creates high-quality PDFs with - selectable text, tables, and complex layouts. The PDF is sent directly to the browser for download. -

    -
    -

    Click the button below to generate a PDF with Japanese text:

    -

    - 日本語のサンプルテキストです。これはPDFレポートに変換されます。 -

    - - {#if error} -
    -

    {error}

    -
    - {/if} - -
    -

    - Font Status: {fontLoaded ? 'Loaded' : 'Loading...'} -

    - {#if fontLoaded && fontLoadingTime > 0} -

    - Font Loading Time: {fontLoadingTime.toFixed(2)} seconds -

    - {/if} -
    - - -
    - - - - - - - -
    - - {#if htmlContent} -
    -

    HTML Preview

    -
    - {@html htmlContent} -
    -
    - {/if} -
    -
    - - diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+server.ts deleted file mode 100644 index 2f4065f7..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/+server.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './pdf/$types'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { jsPDF } from 'jspdf'; - -// Get the current file's directory -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Define the font path relative to the project root -const fontPath = path.resolve(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'); - -// Function to encode file data to base64 encoded string -function base64_encode(file: string): string { - try { - // Read binary data - const bitmap = fs.readFileSync(file); - // Convert binary data to base64 encoded string - return Buffer.from(bitmap).toString('base64'); - } catch (err) { - console.error('Error encoding file to base64:', err); - return ''; - } -} - -export const POST: RequestHandler = async ({ request }) => { - try { - // Parse the request body - const body = await request.json(); - const { text } = body; - - if (!text) { - return new Response(JSON.stringify({ error: 'Text is required' }), { - status: 400, - headers: { - 'Content-Type': 'application/json' - } - }); - } - - // Check if the font file exists - if (!fs.existsSync(fontPath)) { - throw new Error(`Font file not found at: ${fontPath}`); - } - - // Create a new jsPDF instance - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4' - }); - - // Set properties - doc.setProperties({ - title: 'Japanese Text Report (Server-generated)', - subject: 'Sample PDF with Japanese text', - author: 'PDF Generator App (Server)', - creator: 'jsPDF Node.js' - }); - - // Set background color (white) - doc.setFillColor(255, 255, 255); - doc.rect(0, 0, 210, 297, 'F'); - - // Add title - doc.setFontSize(24); - doc.setTextColor(0, 0, 0); - doc.text('Japanese Text Report (Server)', 105, 20, { align: 'center' }); - - // Create a simple representation of the Japanese text - doc.setFontSize(14); - doc.setTextColor(0, 0, 0); - doc.text('Japanese Text:', 20, 40); - - // Create a box to represent where the Japanese text would be - doc.setDrawColor(200, 200, 200); - doc.setFillColor(245, 245, 245); - doc.roundedRect(20, 50, 170, 40, 3, 3, 'FD'); - - // Add a transliteration of the Japanese text - doc.setFontSize(12); - doc.text( - 'Transliteration: "This is Japanese sample text. This will be converted to a PDF report."', - 20, - 110 - ); - - // Add information about the text - doc.setFontSize(12); - doc.text( - 'Original Japanese text: 日本語のサンプルテキストです。これはPDFレポートに変換されます。', - 20, - 130 - ); - doc.text( - 'Note: Server-side PDF generation has limited support for non-Latin characters.', - 20, - 150 - ); - doc.text('For full Japanese text support, use the client-side generation option.', 20, 165); - - // Add a QR code placeholder (just a square for demonstration) - doc.setDrawColor(0, 0, 0); - doc.setFillColor(240, 240, 240); - doc.roundedRect(150, 180, 40, 40, 1, 1, 'FD'); - doc.setFontSize(8); - doc.text('QR Code', 170, 205, { align: 'center' }); - - // Add footer - doc.setFontSize(10); - doc.setTextColor(100, 100, 100); - doc.text(`Generated on server: ${new Date().toLocaleString()}`, 105, 280, { align: 'center' }); - - // Get the PDF as a buffer - const pdfBuffer = Buffer.from(doc.output('arraybuffer')); - - // Return the PDF - return new Response(pdfBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': 'attachment; filename="japanese-report-server.pdf"' - } - }); - } catch (error) { - console.error('Error generating PDF:', error); - return json({ error: 'Failed to generate PDF' }, { status: 500 }); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/html/+server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/html/+server.ts deleted file mode 100644 index 53df512f..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/html/+server.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async () => { - try { - // Create HTML content with Japanese text - const htmlContent = ` - - - - - - Japanese Report - - - -

    日本語レポート

    - -

    これは日本語のテキストを含むHTMLレポートです。このHTMLはPDFに変換されます。

    - -

    データテーブル

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ID名前部署役職
    1田中 太郎営業部マネージャー
    2佐藤 花子開発部シニアエンジニア
    3鈴木 一郎マーケティングディレクター
    4高橋 美咲人事部スペシャリスト
    5伊藤 健太財務部アナリスト
    - -

    概要

    - -

    - このレポートは、jspdf-html2canvasライブラリを使用してHTMLからPDFを生成する例です。 - 日本語のテキストが正しく表示されることを確認するためのテストです。 -

    - - - - - `; - - // Return the HTML content - return new Response(htmlContent, { - status: 200, - headers: { - 'Content-Type': 'text/html; charset=utf-8' - } - }); - } catch (error) { - console.error('Error generating HTML:', error); - return json({ error: 'Failed to generate HTML' }, { status: 500 }); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts deleted file mode 100644 index 967ddbaa..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { TableRow } from '$lib/pdf'; -import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; -import { SessionService } from '$lib/services/SessionService'; -import { error, redirect } from '@sveltejs/kit'; -import fs from 'fs'; -import { DateTime } from 'luxon'; -import path from 'path'; -import PDFDocument from 'pdfkit'; -import { fileURLToPath } from 'url'; -import type { RequestHandler } from './$types'; - -// Get the current file's directory -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Define possible font paths for both development and production -const possibleFontPaths = [ - // Paths for local development - path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), - path.join(process.cwd(), 'src/lib/fonts/NotoSansJP-Regular.ttf'), - - // Paths for production deployment - path.join(process.cwd(), 'server/fonts/NotoSansJP-Regular.ttf'), - path.join(__dirname, '../../../fonts/NotoSansJP-Regular.ttf'), - - // Fallback paths - path.join(__dirname, '../../../../static/fonts/NotoSansJP-Regular.ttf'), - 'static/fonts/NotoSansJP-Regular.ttf' -]; - -export const GET: RequestHandler = async ({ - url, - params, - locals: { safeGetSession, supabase } -}) => { - const { devEui } = params; - const sessionService = new SessionService(supabase); - const sessionResult = await sessionService.getSafeSession(); - - // If no session exists, redirect to login - if (!sessionResult || !sessionResult.user) { - throw redirect(302, '/auth/login'); - } - - try { - const deviceDataService = new DeviceDataService(supabase); - // Create a new PDF document - const doc = new PDFDocument({ - size: 'A4', - margin: 40, - info: { - Title: 'Japanese PDF Report (Server)', - Author: 'PDF Generator App', - Subject: 'Sample PDF with selectable Japanese text' - } - }); - - // Try to load the font using multiple possible paths - let fontLoaded = false; - - for (const fontPath of possibleFontPaths) { - try { - if (fs.existsSync(fontPath)) { - //console.log(`Found font at: ${fontPath}`); - doc.registerFont('NotoSansJP', fontPath); - doc.font('NotoSansJP'); - //console.log('Successfully registered font'); - fontLoaded = true; - break; - } - } catch (e) { - //console.log(`Could not load font from: ${fontPath}`); - } - } - - // If we couldn't load the font from any path, use a fallback approach - if (!fontLoaded) { - try { - // Log the current directory and available files for debugging - //console.log('Current directory:', process.cwd()); - //console.log('__dirname:', __dirname); - - // Try one more approach - use a direct path that should work in Vercel - //console.log('Trying fallback approach with direct path'); - doc.registerFont('NotoSansJP', 'server/fonts/NotoSansJP-Regular.ttf'); - doc.font('NotoSansJP'); - //console.log('Successfully registered font using fallback approach'); - } catch (fontErr) { - console.error('All font loading attempts failed:', fontErr); - console.error( - 'Continuing with default font - Japanese characters may not display correctly' - ); - // Continue with default font - } - } - - // Set the initial position - doc.fontSize(24).text('日本語レポート (サーバー)', { - align: 'center' - }); - - doc.moveDown(); - doc - .fontSize(12) - .text('これは日本語のテキストを含むPDFレポートです。このテキストは選択可能です。', { - align: 'left', - width: 500 - }); - - doc.moveDown(); - doc.fontSize(14).text('データテーブル', { - align: 'left' - }); - - const tokyoNow = DateTime.now().setZone('Asia/Tokyo'); - const startDate = tokyoNow.minus({ months: 1 }).startOf('month').toUTC().toJSDate(); - const endDate = tokyoNow.minus({ months: 1 }).endOf('month').toUTC().toJSDate(); - - const deviceData = await deviceDataService.getDeviceDataForReport({ - devEui, - startDate, - endDate, - timezone: 'Asia/Tokyo', - intervalMinutes: 30 - }); - if (!deviceData || deviceData.length === 0) { - throw error(404, 'No data found for the specified device'); - } - - // Determine numeric keys across dataset - const numericKeys = Array.from( - new Set( - deviceData.flatMap((record) => - Object.entries(record as Record) - .filter( - ([key, value]) => - key !== 'dev_eui' && key !== 'created_at' && typeof value === 'number' - ) - .map(([key]) => key) - ) - ) - ).sort(); - - if (numericKeys.length === 0) { - throw error(404, 'No numeric data found for the specified device'); - } - - const dataHeaderTable: TableRow = { - header: { label: '日時', width: 70 }, - cells: numericKeys.map((key) => ({ - label: key, - width: 60 - })) - }; - - const dataRowsTable: TableRow[] = deviceData.map((record) => { - const recordData = record as Record; - const createdAt = record.created_at ?? recordData.created_at; - const timestampLabel = - typeof createdAt === 'string' - ? DateTime.fromISO(createdAt, { zone: 'utc' }) - .setZone('Asia/Tokyo') - .toFormat('yyyy-MM-dd HH:mm') - : DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm'); - - return { - header: { - label: timestampLabel, - value: createdAt, - width: 70 - }, - cells: numericKeys.map((key) => { - const value = recordData[key]; - return { - label: key, - value: typeof value === 'number' ? value : undefined, - width: 60 - }; - }) - }; - }); - - createPDFDataTable({ - doc, - dataHeader: dataHeaderTable, - dataRows: dataRowsTable, - config: { timezone: 'Asia/Tokyo' } - }); - - // Add generation timestamp - doc - .fontSize(10) - .text(`生成日時: ${DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss')}`, { - align: 'right' - }); - - // Finalize the PDF - doc.end(); - - // Get the PDF as a buffer - const chunks: Buffer[] = []; - doc.on('data', (chunk) => chunks.push(Buffer.from(chunk))); - - return new Promise((resolve, reject) => { - doc.on('end', () => { - const pdfBuffer = Buffer.concat(chunks); - resolve( - new Response(pdfBuffer, { - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': 'attachment; filename="japanese-report-pdfkit.pdf"' - } - }) - ); - }); - - doc.on('error', (err) => { - reject(error(500, err.message)); - }); - }); - } catch (err) { - console.error('Error generating PDF with PDFKit:', err); - throw error(500, 'Failed to generate PDF'); - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+layout.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+layout.svelte deleted file mode 100644 index 8629b390..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+layout.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -
    - -
    -
    -

    {$_('Device Settings')}

    - -
    -
    - {@render children()} -
    -
    - - diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts deleted file mode 100644 index 8e91cf22..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { error, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { LocationService } from '$lib/services/LocationService'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceOwnersRepository } from '$lib/repositories/DeviceOwnersRepository'; - -/** - * Load the device owner’s ID and all locations for the current user. - * User session, `devEui` and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ locals: { supabase } }) => { - try { - // Get error handler from container - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const locationRepository = new LocationRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepository, deviceRepository); - - // Don’t `await` the promise, stream the data instead - const locations = locationService.getAllLocations(); - - return { - locations // streamed - }; - } catch (err) { - console.error('Error loading device rules:', err); - throw error(500, 'Internal Server Error'); - } -}; - -/** - * Actions for device rule management - */ -export const actions: Actions = { - /** - * Create a new rule with criteria - */ - updateGeneralSettings: async ({ request, params, locals }) => { - try { - const devEui = params.devEui; - const data = await request.formData(); - - // Create SessionService with per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - if (!sessionResult?.session || !sessionResult?.user) { - return { success: false, error: 'Authentication required' }; - } - - if (!sessionResult.user.id) { - return { success: false, error: 'User ID not found in session' }; - } - - if (!devEui) { - return { success: false, error: 'Device EUI is required' }; - } - const deviceRepository = new DeviceRepository(locals.supabase, new ErrorHandlingService()); - const deviceService = new DeviceService(deviceRepository); - const device = await deviceService.getDeviceByEui(devEui); - const deviceOwnersRepository = new DeviceOwnersRepository( - locals.supabase, - new ErrorHandlingService() - ); - const owners = await deviceOwnersRepository.findByDeviceEui(devEui); - - if (!device) { - return { success: false, error: 'Device not found' }; - } - - let isOwner = owners.find( - (owner) => owner.user_id === sessionResult?.user?.id && owner.permission_level === 1 - ); - if (device.user_id !== sessionResult.user.id && !isOwner) { - return { success: false, error: 'Unauthorized to update this device' }; - } - - const deviceToUpdate = { ...device }; - deviceToUpdate.name = data.get('name')?.toString() || device.name; - - const newLocation = data.get('location_id'); - if (newLocation) { - deviceToUpdate.location_id = +newLocation; - } - - const response = await deviceService.updateDevice(devEui, deviceToUpdate); - if (!response) { - return { success: false, error: 'Failed to update device settings' }; - } - return { success: true, message: 'Device settings updated successfully' }; - } catch (err) { - console.error('Error updating device settings:', err); - return { success: false, error: 'Internal Server Error' }; - } - }, - deleteDevice: async ({ params, locals }) => { - const { devEui } = params; - - if (!devEui) { - return { success: false, error: 'Device EUI is required' }; - } - - const sessionService = new SessionService(locals.supabase); - const { session, user } = await sessionService.getSafeSession(); - - if (!session || !user) { - return { success: false, error: 'Authentication required' }; - } - - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - const deviceOwnersRepository = new DeviceOwnersRepository(locals.supabase, errorHandler); - - const device = await deviceService.getDeviceByEui(devEui); - - if (!device) { - return { success: false, error: 'Device not found' }; - } - - const owners = await deviceOwnersRepository.findByDeviceEui(devEui); - const isOwner = - device.user_id === user.id || - owners.some((owner) => owner.user_id === user.id && owner.permission_level === 1); - - if (!isOwner) { - return { success: false, error: 'Unauthorized to delete this device' }; - } - - try { - const deleted = await deviceService.deleteDevice(devEui); - - if (!deleted) { - return { success: false, error: 'Failed to delete device' }; - } - - return { success: true }; - } catch (err) { - console.error('Error deleting device:', err); - return { - success: false, - error: err instanceof Error ? err.message : 'Internal Server Error' - }; - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte deleted file mode 100644 index 4c1d4f26..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - - - {$_('Device Settings')} - CropWatch - - -
    -
    -
    -
    -

    {$_('General')}

    -

    {$_('Manage the device information.')}

    -
    -
    -
    { - return async ({ result, update }) => { - if (result.data.success) { - success('Settings updated successfully!'); - update(); - } else { - error('Failed to update settings'); - } - }; - }} - use:formValidation - > -
    - - {$_('Device Type')} - - {#if device?.cw_device_type?.name} - - {device.cw_device_type.name} - - {:else} - {$_('Unknown')} - {/if} -
    -
    - - {$_('EUI')} - - {device?.dev_eui || $_('Unknown')} -
    -
    - - {$_('Installed Date')} - - {device?.installed_at ? formatDateOnly(device.installed_at) : $_('Unknown')} -
    -
    - - {$_('Coordinates')} - - {#if device?.lat && device?.long} - - {device.lat}, {device.long} - {:else} - {$_('Unknown')} - {/if} -
    -
    - - {#if isOwner} - - {:else} - {device?.name} - {/if} -
    -
    - - {#await data.locations} - {$_('Loading...')} - {:then locations} - {#if isOwner} - - {:else} - {locations.find((loc) => loc.location_id === device?.location_id)?.name || - 'Unknown Location'} - {#if device?.location_id} - ({device.location_id}) - {/if} - {/if} - {/await} -
    -
    - {#if isOwner} - - {/if} -
    -
    -
    - - {#if isOwner} -
    -

    {$_('Dangerous Zone')}

    -
    - -
    - - {#snippet title()} - {$_('Delete Device & Associated Data')} - {/snippet} - {#snippet body()} - {$_('delete_device_warning')} - {/snippet} - {#snippet footer()} -
    - -
    { - if (result.type === 'success' && result.data.success) { - showDeleteDialog = false; - success($_('Device removed successfully.')); - window.location.assign('/app/dashboard'); - } else { - error( - result.type === 'failure' && result.data?.error - ? result.data.error - : $_('Failed to delete device.') - ); - } - }} - > - -
    -
    - {/snippet} -
    -
    - {/if} -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.server.ts deleted file mode 100644 index 8938d62c..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.server.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { error, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { DeviceService } from '$lib/services/DeviceService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { DeviceOwnersRepository } from '$lib/repositories/DeviceOwnersRepository'; -import { DeviceOwnersService } from '$lib/services/DeviceOwnersService'; - -/** - * Load device managers for a specific device EUI. - * User session, `devEui` and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ params, locals: { supabase } }) => { - const { devEui } = params; - - try { - // Get error handler from container - const errorHandler = new ErrorHandlingService(); - // Create device owners repository and service - const deviceOwnersRepository = new DeviceOwnersRepository(supabase, errorHandler); - const deviceOwnersService = new DeviceOwnersService(deviceOwnersRepository); - - // Get device owners with profile information - // Don’t `await` the promise, stream the data instead - const deviceOwners = deviceOwnersService.getWithProfilesByDeviceEui(devEui); - - return { - deviceOwners // streamed - }; - } catch (err) { - console.error('Error loading device rules:', err); - throw error(500, 'Internal Server Error'); - } -}; - -/** - * Actions for device permission management - */ -export const actions: Actions = { - /** - * Add a user to a device with specified permissions - */ - addUserToDevice: async ({ request, params, locals }) => { - try { - const devEui = params.devEui; - const data = await request.formData(); - const userId = data.get('userId') as string; - const permissionLevel = parseInt(data.get('permissionLevel') as string); - - // Create SessionService with per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - if (!sessionResult?.session || !sessionResult?.user) { - return { success: false, error: 'Authentication required' }; - } - - if (!devEui || !userId) { - return { success: false, error: 'Device EUI and User ID are required' }; - } - - if (isNaN(permissionLevel) || permissionLevel < 1 || permissionLevel > 3) { - return { - success: false, - error: 'Permission level must be between 1 (Admin) and 3 (Viewer)' - }; - } - - // Initialize services - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - const deviceOwnersRepository = new DeviceOwnersRepository(locals.supabase, errorHandler); - const deviceOwnersService = new DeviceOwnersService(deviceOwnersRepository); - - // Verify device exists and user has permission to manage it - const device = await deviceService.getDeviceByEui(devEui); - if (!device) { - return { success: false, error: 'Device not found' }; - } - - // Check if current user has permission to manage this device (admin level or device owner) - const hasPermission = await deviceOwnersService.hasPermission( - devEui, - sessionResult.user.id, - 1 - ); // Level 1 is admin level - if (!hasPermission && device.user_id !== sessionResult.user.id) { - return { success: false, error: 'Unauthorized to manage this device' }; - } - - // Check if user is already added to the device - const existingOwner = await deviceOwnersService.getByDeviceAndUser(devEui, userId); - if (existingOwner) { - return { success: false, error: 'User is already added to this device' }; - } - - // Add user to device - await deviceOwnersService.addUserToDevice(devEui, userId, permissionLevel); - - return { success: true, message: 'User added to device successfully' }; - } catch (err) { - console.error('Error adding user to device:', err); - return { success: false, error: 'Internal Server Error' }; - } - }, - - /** - * Update user permissions for a device - */ - updatePermission: async ({ request, params, locals }) => { - try { - const devEui = params.devEui; - const data = await request.formData(); - const deviceOwnerId = parseInt(data.get('ownerId') as string); - const permissionLevel = parseInt(data.get('permissionLevel') as string); - - // Create SessionService with per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - if (!sessionResult?.session || !sessionResult?.user) { - return { success: false, error: 'Authentication required' }; - } - - if (!devEui || isNaN(deviceOwnerId)) { - return { success: false, error: 'Device EUI and Device Owner ID are required' }; - } - - if (isNaN(permissionLevel) || permissionLevel < 1 || permissionLevel > 3) { - return { - success: false, - error: 'Permission level must be between 1 (Admin) and 3 (Viewer)' - }; - } - - // Initialize services - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(locals.supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - const deviceOwnersRepository = new DeviceOwnersRepository(locals.supabase, errorHandler); - const deviceOwnersService = new DeviceOwnersService(deviceOwnersRepository); - - // Verify device exists and user has permission to manage it - const device = await deviceService.getDeviceByEui(devEui); - if (!device) { - return { success: false, error: 'Device not found' }; - } - - // Check if current user has permission to manage this device - const hasPermission = await deviceOwnersService.hasPermission( - devEui, - sessionResult.user.id, - 1 - ); // Level 1 is admin level - if (!hasPermission && device.user_id !== sessionResult.user.id) { - return { success: false, error: 'Unauthorized to manage this device' }; - } - - // Update permission - const updateResult = await deviceOwnersService.updatePermission( - deviceOwnerId, - permissionLevel - ); - //console.log('Update result:', updateResult); - - return { success: true, message: 'Permission updated successfully' }; - } catch (err) { - console.error('Error updating device permission:', err); - return { success: false, error: 'Internal Server Error' }; - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.svelte deleted file mode 100644 index d4e8fa09..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/permissions/+page.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {$_('Permissions')} - CropWatch - - -
    -
    -
    -

    {$_('Permissions')}

    -

    - {$_('manage_permissions_description')} -

    -
    -
    - -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.server.ts deleted file mode 100644 index 0c44b6dc..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.server.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { ReportService } from '$lib/services/ReportService'; -import { ReportRepository } from '$lib/repositories/ReportRepository'; -import { ReportAlertPointRepository } from '$lib/repositories/ReportAlertPointRepository'; -import { ReportRecipientRepository } from '$lib/repositories/ReportRecipientRepository'; -import { ReportUserScheduleRepository } from '$lib/repositories/ReportUserScheduleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { error, fail } from '@sveltejs/kit'; - -export const load: PageServerLoad = async ({ params, locals }) => { - try { - const devEui = params.devEui; - - if (!devEui) { - throw error(400, 'Device EUI is required'); - } - - // Check if user is authenticated - const session = locals.session; - if (!session) { - throw error(401, 'Authentication required'); - } - - // Create service dependencies - const errorHandler = new ErrorHandlingService(); - const reportRepo = new ReportRepository(locals.supabase, errorHandler); - const alertPointRepo = new ReportAlertPointRepository(locals.supabase, errorHandler); - const recipientRepo = new ReportRecipientRepository(locals.supabase, errorHandler); - const scheduleRepo = new ReportUserScheduleRepository(locals.supabase, errorHandler); - - // Create report service - const reportService = new ReportService( - reportRepo, - alertPointRepo, - recipientRepo, - scheduleRepo - ); - - // Load reports for the device - const reports = await reportService.getReportsWithDetailsByDeviceEui(devEui); - const reportCount = await reportService.countReportsByDeviceEui(devEui); - - return { - devEui, - reports, - reportCount - }; - } catch (err) { - console.error('Error loading reports:', err); - - if (err instanceof Error && 'status' in err) { - throw err; // Re-throw SvelteKit errors - } - - throw error(500, 'Failed to load reports'); - } -}; - -export const actions: Actions = { - createReport: async ({ request, locals, params }) => { - try { - const devEui = params.devEui; - - if (!devEui) { - return fail(400, { success: false, error: 'Device EUI is required' }); - } - - // Check if user is authenticated - const session = locals.session; - if (!session) { - return fail(401, { success: false, error: 'Authentication required' }); - } - - const formData = await request.formData(); - const name = formData.get('name') as string; - - if (!name) { - return fail(400, { success: false, error: 'Report name is required' }); - } - - // Create service dependencies - const errorHandler = new ErrorHandlingService(); - const reportRepo = new ReportRepository(locals.supabase, errorHandler); - const alertPointRepo = new ReportAlertPointRepository(locals.supabase, errorHandler); - const recipientRepo = new ReportRecipientRepository(locals.supabase, errorHandler); - const scheduleRepo = new ReportUserScheduleRepository(locals.supabase, errorHandler); - - // Create report service - const reportService = new ReportService( - reportRepo, - alertPointRepo, - recipientRepo, - scheduleRepo - ); - - // Create the report - const report = await reportService.createReport({ - name, - dev_eui: devEui - }); - - return { success: true, report }; - } catch (err) { - console.error('Error creating report:', err); - return fail(500, { success: false, error: 'Failed to create report' }); - } - }, - - deleteReport: async ({ request, locals, params }) => { - try { - const devEui = params.devEui; - - if (!devEui) { - return fail(400, { success: false, error: 'Device EUI is required' }); - } - - // Check if user is authenticated - const session = locals.session; - if (!session) { - return fail(401, { success: false, error: 'Authentication required' }); - } - - const formData = await request.formData(); - const reportId = formData.get('reportId') as string; - - if (!reportId) { - return fail(400, { success: false, error: 'Report ID is required' }); - } - - // Create service dependencies - const errorHandler = new ErrorHandlingService(); - const reportRepo = new ReportRepository(locals.supabase, errorHandler); - const alertPointRepo = new ReportAlertPointRepository(locals.supabase, errorHandler); - const recipientRepo = new ReportRecipientRepository(locals.supabase, errorHandler); - const scheduleRepo = new ReportUserScheduleRepository(locals.supabase, errorHandler); - - // Create report service - const reportService = new ReportService( - reportRepo, - alertPointRepo, - recipientRepo, - scheduleRepo - ); - - // Delete the report - await reportService.deleteReport(reportId); - - return { success: true }; - } catch (err) { - console.error('Error deleting report:', err); - return fail(500, { success: false, error: 'Failed to delete report' }); - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.svelte deleted file mode 100644 index a8339e87..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/+page.svelte +++ /dev/null @@ -1,345 +0,0 @@ - - -
    -
    -
    -

    - {$_('Reports')} - {reportCount > 0 ? `(${reportCount})` : ''} -

    -

    - Customizable reports to analyze your device data over time. Create and manage reports to - track trends, monitor performance, and gain insights into your device's behavior. -

    -
    - -
    - {#if reportCount >= 0} -

    - -

    - {:else} - - {/if} -
    -
    - - -
    - {#if reports && reports.length > 0} -
    -
    -

    Device Reports

    -

    - Reports configured for device: {devEui} -

    -
    - -
    - {#each reports as report} -
    -
    -
    -

    - {report.name} -

    -

    - Report ID: {report.report_id} -

    -

    - Created: {formatDate(report.created_at)} -

    - - - {#if report.alert_points && report.alert_points.length > 0} -
    - - {report.alert_points.length} alert point{report.alert_points.length !== 1 - ? 's' - : ''} - -
    - {/if} - - - {#if report.recipients && report.recipients.length > 0} -
    - - {report.recipients.length} recipient{report.recipients.length !== 1 - ? 's' - : ''} - -
    - {/if} - - - {#if report.schedules && report.schedules.length > 0} -
    - - {report.schedules.length} schedule{report.schedules.length !== 1 ? 's' : ''} - -
    - {/if} -
    - -
    - - - -
    -
    -
    - {/each} -
    -
    - {:else} - -
    - - - -

    - No reports found -

    -

    - No reports have been created for this device yet. -

    -
    - -
    -
    - {/if} -
    -
    - - - - {#snippet title()} - Create New Report - {/snippet} - - {#snippet body()} -
    -
    - - -
    - -
    -

    This will create a new report for device: {devEui}

    -
    - - {#if form?.error} -
    - {form.error} -
    - {/if} -
    - {/snippet} - - {#snippet footer()} - -
    - - -
    - {/snippet} -
    - - - - {#snippet title()} - Delete Report - {/snippet} - - {#snippet body()} -
    -

    - Are you sure you want to delete the report "{reportToDelete?.name}"? -

    - -

    - This action cannot be undone. All alert points, recipients, and schedules associated with - this report will also be deleted. -

    - - {#if form?.error} -
    - {form.error} -
    - {/if} -
    - {/snippet} - - {#snippet footer()} - -
    - - -
    - {/snippet} -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts deleted file mode 100644 index 34783680..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { ReportService } from '$lib/services/ReportService'; -import { ReportRepository } from '$lib/repositories/ReportRepository'; -import { ReportAlertPointRepository } from '$lib/repositories/ReportAlertPointRepository'; -import { ReportRecipientRepository } from '$lib/repositories/ReportRecipientRepository'; -import { ReportUserScheduleRepository } from '$lib/repositories/ReportUserScheduleRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { error, fail } from '@sveltejs/kit'; -import type { ReportAlertPoint, ReportRecipient, ReportUserSchedule } from '$lib/models/Report'; -import { DeviceDataService } from '$lib/services/DeviceDataService'; - -export const load: PageServerLoad = async ({ params, locals, url }) => { - try { - const devEui = params.devEui; - const location_id = params.location_id; - const reportId = url.searchParams.get('reportId'); - - if (!devEui) { - throw error(400, 'Device EUI is required'); - } - - if (!location_id) { - throw error(400, 'Location ID is required'); - } - - // Check if user is authenticated - const session = locals.session; - if (!session) { - throw error(401, 'Authentication required'); - } - - const errorHandler = new ErrorHandlingService(); - const deviceRepo = new ReportRepository(locals.supabase, errorHandler); - const dataService = new DeviceDataService(locals.supabase, errorHandler); - // If reportId is provided, load existing report data - let report = null; - let alertPoints: ReportAlertPoint[] = []; - let recipients: ReportRecipient[] = []; - let schedules: ReportUserSchedule[] = []; - let dataKeys: string[] = []; - const latestData = await dataService.getLatestDeviceData(devEui); // pull the latest data for the device keys - if (!latestData) { - throw error(404, 'No data found for this device'); - } - - const keysObj = Object.keys(latestData) - .filter((k) => (latestData as any)[k] != null) - .reduce( - (a: Record, k: string) => ({ ...a, [k]: (latestData as any)[k] }), - {} as Record - ); - delete keysObj['dev_eui']; // remove dev_eui as it's not a data key - delete keysObj['created_at']; // remove created_at as it's not a data key - delete keysObj['is_simulated']; // remove updated_at as it's not a data key - dataKeys = Object.keys(keysObj); // get only the keys - - if (reportId) { - // Create service dependencies - const reportRepo = new ReportRepository(locals.supabase, errorHandler); - const alertPointRepo = new ReportAlertPointRepository(locals.supabase, errorHandler); - const recipientRepo = new ReportRecipientRepository(locals.supabase, errorHandler); - const scheduleRepo = new ReportUserScheduleRepository(locals.supabase, errorHandler); - - // Create report service - const reportService = new ReportService( - reportRepo, - alertPointRepo, - recipientRepo, - scheduleRepo - ); - - // Load report and related data - report = await reportService.getReportByReportId(reportId); - if (!report) { - throw error(404, 'Report not found'); - } - - // Verify the report belongs to the specified device - if (report.dev_eui !== devEui) { - throw error(403, 'Report does not belong to this device'); - } - - alertPoints = await reportService.getAlertPointsByReportId(reportId); - recipients = await reportService.getRecipientsByReportId(reportId); - schedules = await reportService.getSchedulesByReportId(reportId); - } - - alertPoints.map((point) => { - point.operator = point.operator === null ? 'null' : point.operator; - }); - - let { data: previouslyUsedAlertColors, error: dpe } = await locals.supabase - .from('report_alert_points') - .select('name, hex_color') - .eq('user_id', session.user.id) - .order('created_at', { ascending: false }); - - return { - devEui, - locationId: location_id, - report, - alertPoints, - previouslyUsedAlertColors: previouslyUsedAlertColors || [], - recipients, - schedules, - dataKeys, - isEditing: !!reportId - }; - } catch (err) { - console.error('Error loading create/edit report page:', err); - - if (err instanceof Error && 'status' in err) { - throw err; // Re-throw SvelteKit errors - } - - throw error(500, 'Failed to load page'); - } -}; - -export const actions: Actions = { - default: async ({ request, locals, params }) => { - try { - const devEui = params.devEui; - const location_id = params.location_id; - - if (!devEui) { - return fail(400, { success: false, error: 'Device EUI is required' }); - } - - if (!location_id) { - return fail(400, { success: false, error: 'Location ID is required' }); - } - - // Check if user is authenticated - const session = locals.session; - if (!session) { - return fail(401, { success: false, error: 'Authentication required' }); - } - - const formData = await request.formData(); - // const name = formData.get('name') as string; - - const nameEntry = formData.get('name'); - if (typeof nameEntry !== 'string' || !nameEntry.trim()) { - return fail(400, { success: false, error: 'Report name is required' }); - } - const name = nameEntry.trim(); - const nameData = { name }; - - const reportIdEntry = formData.get('reportId'); - const reportId = - typeof reportIdEntry === 'string' && reportIdEntry.length > 0 ? reportIdEntry : undefined; - const alertPointsJson = formData.get('alertPoints'); - const recipientsJson = formData.get('recipients'); - const schedulesJson = formData.get('schedules'); - - if (!name) { - return fail(400, { success: false, error: 'Report name is required' }); - } - - let alertPoints = []; - let recipients = []; - let schedules = []; - - try { - alertPoints = - typeof alertPointsJson === 'string' && alertPointsJson.length > 0 - ? JSON.parse(alertPointsJson) - : []; - recipients = - typeof recipientsJson === 'string' && recipientsJson.length > 0 - ? JSON.parse(recipientsJson) - : []; - schedules = - typeof schedulesJson === 'string' && schedulesJson.length > 0 - ? JSON.parse(schedulesJson) - : []; - } catch (parseError) { - return fail(400, { success: false, error: 'Invalid data format' }); - } - - // Create service dependencies - const errorHandler = new ErrorHandlingService(); - const reportRepo = new ReportRepository(locals.supabase, errorHandler); - const alertPointRepo = new ReportAlertPointRepository(locals.supabase, errorHandler); - const recipientRepo = new ReportRecipientRepository(locals.supabase, errorHandler); - const scheduleRepo = new ReportUserScheduleRepository(locals.supabase, errorHandler); - - // Create report service - const reportService = new ReportService( - reportRepo, - alertPointRepo, - recipientRepo, - scheduleRepo - ); - - let report; - - if (reportId) { - // Edit existing report - - report = await reportService.upsertReport({ - report_id: reportId, - name: nameData.name, - dev_eui: devEui - }); - - // Delete existing related data and recreate - await reportService.deleteAlertPointsByReportId(reportId); - await reportService.deleteRecipientsByReportId(reportId); - await reportService.deleteSchedulesByReportId(reportId); - } else { - // Create new report - report = await reportService.createReport({ - name: nameData.name, - dev_eui: devEui - }); - } - - // Create/recreate alert points - for (const point of alertPoints) { - await reportService.createAlertPoint({ - report_id: report.report_id, - name: point.name, - operator: point.operator == 'null' ? null : point.operator, - min: point.min, - max: point.max, - value: point.value, - hex_color: point.operator == 'null' ? '#FFFFFF' : point.hex_color, - data_point_key: point.data_point_key - }); - } - - // Create/recreate recipients - for (const recipient of recipients) { - await reportService.createRecipient({ - report_id: report.report_id, - email: recipient.email, - name: recipient.name, - communication_method: 1 //recipient.communication_method, - }); - } - - // Create/recreate schedules - for (const schedule of schedules) { - await reportService.createSchedule({ - report_id: report.report_id, - dev_eui: devEui, - user_id: session.user.id, - end_of_week: schedule.frequency === 'weekly', - end_of_month: schedule.frequency === 'monthly', - is_active: true - }); - } - - // Return success so the client can show a toast and navigate - return { success: true }; - } catch (err) { - console.error('Error creating/updating report:', err); - - if (err instanceof Error && 'status' in err) { - throw err; // Re-throw SvelteKit errors (including redirects) - } - - return fail(500, { success: false, error: 'Failed to create/update report' }); - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte deleted file mode 100644 index 9f8e3e34..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte +++ /dev/null @@ -1,750 +0,0 @@ - - -
    -
    -
    -

    {isEditing ? 'Edit Report' : 'Create Report'}

    -

    - {isEditing ? 'Edit' : 'Create a new'} report for device: {devEui} -

    -
    - -
    - -
    - {#if isEditing && report} - - {/if} - - -
    -

    Report Details

    - -
    -
    - - -
    -
    -
    - - -
    -
    -

    Alert Points

    - -
    - - {#if validationErrors.length > 0} -
    -
    -
    - - - -
    -
    -

    Validation Errors

    -
    -
      - {#each validationErrors as error} -
    • {error}
    • - {/each} -
    -
    -
    -
    -
    - {/if} - - {#if alertPoints.length > 0} -
    - -
    - Visual preview of alert points and ranges -
    -
    -
    - -
    - {#each alertPoints as point, i} -
    -
    -
    -
    -
    -
    - - -
    - - -
    -
    - - Alert Point {i + 1} - -
    -
    -
    - -
    - -
    -
    - - -
    - -
    - - -
    -
    - -
    - {#if point.operator === '=' || point.operator === '>' || point.operator === '<'} -
    - - -
    - {:else if point.operator === 'range'} -
    - - -
    -
    - - -
    - {/if} -
    -
    - {/each} -
    - {:else} -
    -

    No alert points configured. Add an alert point to get started.

    -
    - {/if} -
    - - -
    -
    -

    Recipients

    - -
    - - {#if recipients.length > 0} -
    - {#each recipients as recipient, i} -
    -
    - Recipient {i + 1} - -
    - -
    -
    - - -
    - -
    - - -
    -
    -
    - {/each} -
    - {:else} -
    -

    No recipients configured. Add a recipient to get started.

    -
    - {/if} -
    - - -
    -
    -

    Schedules

    - -
    - - {#if schedules.length > 0} -
    - {#each schedules as schedule, i} -
    -
    - Schedule {i + 1} - -
    - -
    -
    - - -
    - -
    - - -
    -
    -
    - {/each} -
    - {:else} -
    -

    No schedules configured. Add a schedule to get started.

    -
    - {/if} -
    - - -
    - - - // Ensure at least one alert point has a valid data key and operator - // @todo Filter out non-numeric data keys - p.data_point_key !== 'id' && !!p.operator && p.value !== undefined - )} - showDatePicker={false} - types={['pdf']} - alertPoints={alertPoints - .filter((p) => !!p.data_point_key) - .map((point) => ({ ...point, id: 0, created_at: '', user_id: '' }) as ReportAlertPoint)} - /> - -
    - - - - - -
    -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts deleted file mode 100644 index 457a4b0c..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import type { Actions } from './$types'; -import type { - RuleUpdate, - RuleInsert, - RuleCriteriaInsert, - RuleCriteriaUpdate -} from '$lib/models/Rule'; -import { SessionService } from '$lib/services/SessionService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { RuleRepository } from '$lib/repositories/RuleRepository'; -import { NotifierTypeRepository } from '$lib/repositories/NotifierTypeRepository'; -import { RuleService } from '$lib/services/RuleService'; - -/** - * Load the device notification rules and notifier types. - * User session, `devEui` and `locationId` are already validated in the layout server load. - */ -export const load: PageServerLoad = async ({ params, locals }) => { - const { devEui } = params; - - try { - // Create SessionService with per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - // Create error handler and repositories - const errorHandler = new ErrorHandlingService(); - - // Create repositories with per-request Supabase client - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - const notifierTypeRepo = new NotifierTypeRepository(locals.supabase, errorHandler); - - // Create services with repositories - const ruleService = new RuleService(ruleRepo); - - // Get rules for the device - // Don’t `await` the promise, stream the data instead - const rules = ruleService.getRulesByDevice(devEui); - - // Get notifier types - // Don’t `await` the promise, stream the data instead - const notifierTypes = notifierTypeRepo.findAll(); - - return { - session: sessionResult.session, - user: sessionResult.user, - rules, // streamed - notifierTypes // streamed - }; - } catch (err) { - console.error('Error loading rules data:', err); - throw error(500, 'Failed to load rules data'); - } -}; - -/** - * Actions for device rule management - */ -export const actions: Actions = { - /** - * Create a new rule with criteria - */ - createRule: async ({ request, params, locals }) => { - try { - const devEui = params.devEui; - const data = await request.formData(); - - // Create SessionService with per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - if (!sessionResult?.session || !sessionResult?.user) { - return { success: false, error: 'Authentication required' }; - } - - if (!devEui) { - throw error(400, 'Device EUI is required'); - } - - // Extract rule data - const name = data.get('name') as string; - const action_recipient = data.get('action_recipient') as string; - const notifier_type = +(data.get('notifier_type') || (0 as number)); - - // Validate required fields - if (!name) { - return { success: false, error: 'Rule name is required' }; - } - - // Generate a unique rule group ID - const ruleGroupId = `rule-${devEui}-${Date.now()}`; - - // Prepare rule data with the device EUI - const ruleData: RuleInsert = { - name, - dev_eui: devEui, - ruleGroupId, - action_recipient, - notifier_type, - profile_id: sessionResult.user?.id || '' - }; - - // Extract criteria data - assuming a JSON string is submitted - const criteriaDataJson = data.get('criteria') as string; - if (!criteriaDataJson) { - return { success: false, error: 'Rule criteria data is required' }; - } - - let criteriaArray: Array<{ - subject: string; - operator: string; - trigger_value: number; - reset_value: number; - }> = []; - - try { - criteriaArray = JSON.parse(criteriaDataJson); - if (!Array.isArray(criteriaArray) || criteriaArray.length === 0) { - return { success: false, error: 'At least one rule criteria is required' }; - } - } catch (err) { - return { success: false, error: 'Invalid criteria data format' }; - } - - // Convert criteria array to the expected format - const criteriaData: RuleCriteriaInsert[] = criteriaArray.map((item) => ({ - subject: item.subject, - operator: item.operator, - trigger_value: item.trigger_value, - reset_value: item.reset_value, - ruleGroupId - })); - - // Create error handler and dependencies - const errorHandler = new ErrorHandlingService(); - - // Create repository with per-request Supabase client - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - - // Create rule service with repository - const ruleService = new RuleService(ruleRepo); - - // Create rule with its criteria - await ruleService.createRuleWithCriteria(ruleData, criteriaData); - - return { success: true }; - } catch (err) { - console.error('Error creating rule:', err); - return { success: false, error: 'Failed to create rule' }; - } - }, - - /** - * Update an existing rule - */ - updateRule: async ({ request, params, locals }) => { - try { - const data = await request.formData(); - const ruleId = parseInt(data.get('ruleId') as string); - - if (isNaN(ruleId)) { - return { success: false, error: 'Invalid rule ID' }; - } - - // Extract rule data - const name = data.get('name') as string; - const action_recipient = data.get('action_recipient') as string; - const notifier_type = +(data.get('notifier_type') || (0 as number)); - - // Validate required fields - if (!name) { - return { success: false, error: 'Rule name is required' }; - } - - // Prepare rule update data - const ruleData: RuleUpdate = { - name, - action_recipient, - notifier_type - }; - - // Create error handler and dependencies - const errorHandler = new ErrorHandlingService(); - - // Create repository with per-request Supabase client - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - - // Create rule service with repository - const ruleService = new RuleService(ruleRepo); - - // Update rule - const updatedRule = await ruleService.updateRule(ruleId, ruleData); - if (!updatedRule) { - return { success: false, error: 'Rule not found' }; - } - - // Extract criteria data - assuming a JSON string is submitted with array of criteria - const criteriaDataJson = data.get('criteria') as string; - const deletedCriteriaIdsJson = data.get('deletedCriteriaIds') as string; - - // Process deleted criteria IDs if provided - if (deletedCriteriaIdsJson) { - try { - const deletedIds: number[] = JSON.parse(deletedCriteriaIdsJson); - for (const id of deletedIds) { - const deleted = await ruleService.deleteRuleCriteria(id); - if (!deleted) { - console.warn(`Failed to delete criteria with ID: ${id}`); - } - } - } catch (err) { - console.error('Error parsing deleted criteria IDs:', err); - // Continue with the update even if deleting fails - } - } - - // Validate and process criteria data - if (!criteriaDataJson) { - return { success: false, error: 'Rule criteria data is required' }; - } - - let criteriaArray: Array<{ - id?: number; - subject: string; - operator: string; - trigger_value: number; - reset_value: number; - }> = []; - - try { - criteriaArray = JSON.parse(criteriaDataJson); - if (!Array.isArray(criteriaArray) || criteriaArray.length === 0) { - return { success: false, error: 'At least one rule criteria is required' }; - } - } catch (err) { - return { success: false, error: 'Invalid criteria data format' }; - } - - // Process each criteria item - for (const item of criteriaArray) { - // Validate the criteria item - if ( - !item.subject || - !item.operator || - typeof item.trigger_value !== 'number' || - typeof item.reset_value !== 'number' - ) { - return { success: false, error: 'Invalid criteria data' }; - } - - // Prepare criteria data - const criteriaData: RuleCriteriaUpdate = { - subject: item.subject, - operator: item.operator, - trigger_value: item.trigger_value, - reset_value: item.reset_value - }; - - if (item.id) { - // Update existing criteria - const updatedCriteria = await ruleService.updateRuleCriteria(item.id, criteriaData); - if (!updatedCriteria) { - return { success: false, error: `Failed to update rule criteria with ID: ${item.id}` }; - } - } else { - // Create new criteria for this rule - const newCriteriaInsert: RuleCriteriaInsert = { - ruleGroupId: updatedRule.ruleGroupId, - subject: item.subject, - operator: item.operator, - trigger_value: item.trigger_value, - reset_value: item.reset_value - }; - const createdCriteria = await ruleService.createRuleCriteria(newCriteriaInsert); - if (!createdCriteria) { - return { success: false, error: 'Failed to create new rule criteria' }; - } - } - } - - return { success: true }; - } catch (err) { - console.error('Error updating rule:', err); - return { success: false, error: 'Failed to update rule' }; - } - }, - - /** - * Delete a rule - */ - deleteRule: async ({ request, locals }) => { - try { - const data = await request.formData(); - const ruleId = parseInt(data.get('ruleId') as string); - - if (isNaN(ruleId)) { - return { success: false, error: 'Invalid rule ID' }; - } - - // Create error handler and dependencies - const errorHandler = new ErrorHandlingService(); - - // Create repository with per-request Supabase client - const ruleRepo = new RuleRepository(locals.supabase, errorHandler); - - // Create rule service with repository - const ruleService = new RuleService(ruleRepo); - - // Delete the rule - const deleted = await ruleService.deleteRule(ruleId); - - if (!deleted) { - return { success: false, error: 'Failed to delete rule' }; - } - - return { success: true }; - } catch (err) { - console.error('Error deleting rule:', err); - return { success: false, error: 'Failed to delete rule' }; - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.svelte deleted file mode 100644 index 9a46e33d..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.svelte +++ /dev/null @@ -1,700 +0,0 @@ - - - - {$_('Notifications')} - CropWatch - - -
    -
    -
    -

    {$_('Notifications')}

    -

    - {$_('Get notified when the device meets a specific condition.')} -

    -
    - -
    - -
    -
    - - -
    - {#if rules.length === 0} -
    - {$_('No rules found.')} -
    - {:else} - - - - - - - - - - - - {#each rules as rule (rule.id)} - - - - - - - - {/each} - -
    -
    - {$_('Name')} -
    -
    {rule.name}
    -
    -
    - {$_('Method')} -
    -
    {getNotifierTypeLabel(rule.notifier_type || 1)}
    -
    -
    - {$_('Recipients')} -
    -
    - {#if rule.action_recipient} - {#each rule.action_recipient.split(',') as recipient} -
    {recipient.trim()}
    - {/each} - {:else} - - {$_('No recipients')} - - {/if} -
    -
    -
    - {$_('Conditions')} -
    -
    - {#if rule.cw_rule_criteria && rule.cw_rule_criteria.length > 0} - {#each rule.cw_rule_criteria as criteria} -
    - {$_(criteria.subject, { default: 'Unknown' })} - {criteria.operator || '>'} - {criteria.trigger_value || 0} - {#if criteria.reset_value} - (Reset: {criteria.reset_value}) - {/if} -
    - {/each} - {:else} - - {$_('No criteria defined')} - - {/if} -
    -
    - - -
    - {/if} -
    - - - - {#snippet title()} - {$_('Delete Rule')} - {/snippet} - {#snippet body()} -

    - {$_('Are you sure you want to delete this rule? This action cannot be undone.')} -

    -
    - -
    { - isDeleting = true; - - return async ({ result }) => { - isDeleting = false; - confirmDeleteOpen = false; - - if (result.type === 'success') { - if (result.data?.success) { - success($_('Rule deleted successfully')); - // Refresh the page to show updated data - goto( - `/app/dashboard/location/${locationId}/devices/${device.dev_eui}/settings/rules`, - { invalidateAll: true } - ); - } else { - toastError(result.data?.error || $_('Failed to delete rule')); - } - } else { - toastError($_('An error occurred')); - } - }; - }} - use:formValidation - > - - - -
    -
    - {/snippet} -
    - - - - {#snippet title()} - {creatingNewRule ? $_('Create New Rule') : $_('Edit Rule')} - {/snippet} - {#snippet body()} -
    { - isCreating = creatingNewRule; - isUpdating = !creatingNewRule; - - return async ({ result }) => { - isCreating = false; - isUpdating = false; - - if (result.type === 'success') { - if (result.data?.success) { - success( - creatingNewRule - ? $_('Rule created successfully') - : $_('Rule updated successfully') - ); - showForm = false; - editingRuleId = null; - // Force reload all data to update the grid - goto( - `/app/dashboard/location/${locationId}/devices/${device.dev_eui}/settings/rules`, - { invalidateAll: true, replaceState: false } - ).then(() => { - // Force a complete page reload to ensure fresh data - // @todo Replace with `invalidateAll` - window.location.reload(); - }); - } else { - toastError( - result.data?.error || - (creatingNewRule ? $_('Failed to create rule') : $_('Failed to update rule')) - ); - } - } else { - toastError($_('An error occurred')); - } - }; - }} - use:formValidation - > - - - - -
    -
    - - -
    - -
    - - -
    - -
    - -
    - - -
    - - {#if editRuleActionRecipients.length > 0} -
    - {#each editRuleActionRecipients as recipient, index} -
    - {recipient} - -
    - {/each} -
    - - {/if} -
    - -
    -

    {$_('Conditions')}

    -

    - {$_('Define one or more conditions that will trigger this rule.')} -

    - - - {#each editRuleCriteriaItems as criteria, index} -
    - {#if editRuleCriteriaItems.length > 1} - - {/if} - -
    - - {#if criteria.id} - - {/if} - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    -
    - {/each} - - - - - - - - -
    -
    - -
    - - - -
    -
    - {/snippet} -
    -
    diff --git a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts deleted file mode 100644 index 93299146..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import type { Session, User as AuthUser } from '@supabase/supabase-js'; -import type { DeviceInsert } from '$lib/models/Device'; -import type { LocationUser } from '$lib/models/LocationUser'; -import { PermissionLevel } from '$lib/models/LocationUser'; - -import { DeviceService } from '$lib/services/DeviceService'; -import { LocationService } from '$lib/services/LocationService'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceTypeRepository } from '$lib/repositories/DeviceTypeRepository'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import type { SupabaseClient } from '@supabase/supabase-js'; - -interface PageData { - currentUser: AuthUser; - usersInLocation: LocationUser[]; - permissionTypes: Array<[string, PermissionLevel]>; - locationId: number; - deviceTypes: Array<{ id: number; name: string }>; -} - -export const load: PageServerLoad = async (event) => { - const { params, locals } = event; - const session = locals.session as Session | null; // Assuming session is directly on locals - - if (!session?.user) { - throw fail(401, { message: 'Unauthorized' }); - } - - const currentUser = session.user; - const location_id = parseInt(params.location_id || '0', 10); - - if (!location_id) { - throw fail(400, { message: 'Invalid location ID' }); - } - - const supabase = locals.supabase as SupabaseClient; - const errorHandler = new ErrorHandlingService(); - const locationRepository = new LocationRepository(supabase, errorHandler); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepository, deviceRepository); - const deviceTypeRepository = new DeviceTypeRepository(supabase, errorHandler); - - try { - const usersInLocation = await locationService.getLocationUsers(location_id); - const deviceTypeResults = await deviceTypeRepository.findAll(); - const deviceTypes = deviceTypeResults.map((type) => ({ - id: type.id, - name: type.name ?? '' - })); - const permissionTypes: Array<[string, PermissionLevel]> = Object.entries(PermissionLevel) - .filter(([key]) => isNaN(Number(key))) // Get string keys from enum - .map(([key, value]) => [key, value as PermissionLevel] as [string, PermissionLevel]); - - return { - currentUser, - usersInLocation: usersInLocation.filter((u) => u.user_id !== currentUser.id), - permissionTypes, - locationId: location_id, - deviceTypes - } satisfies PageData; - } catch (error) { - console.error('Error loading data for create device page:', error); - throw fail(500, { - message: - 'Failed to load page data. ' + (error instanceof Error ? error.message : 'Unknown error') - }); - } -}; - -export const actions: Actions = { - createDevice: async (event) => { - const { request, locals, params } = event; - const session = locals.session as Session | null; // Assuming session is directly on locals - - if (!session?.user) { - return fail(401, { message: 'Unauthorized' }); - } - const currentUserId = session.user.id; - const location_id = parseInt(params.location_id || '0', 10); - - if (!location_id) { - return fail(400, { message: 'Invalid location ID' }); - } - - const formData = await request.formData(); - const name = formData.get('deviceName') as string; - const devEui = formData.get('devEui') as string; - // Ensure latitude and longitude are parsed as numbers - const latitudeStr = formData.get('latitude') as string; - const longitudeStr = formData.get('longitude') as string; - const userPermissionsData = formData.get('userPermissionsData') as string; - const deviceTypeStr = formData.get('deviceType') as string; - - const latitude = latitudeStr ? parseFloat(latitudeStr) : undefined; - const longitude = longitudeStr ? parseFloat(longitudeStr) : undefined; - const deviceType = deviceTypeStr ? parseInt(deviceTypeStr, 10) : undefined; - - if (!name || !devEui || userPermissionsData === null) { - // lat/long can be optional depending on DB - return fail(400, { message: 'Missing required device information or permissions data.' }); - } - if (latitude !== undefined && isNaN(latitude)) { - return fail(400, { message: 'Invalid latitude value.' }); - } - if (longitude !== undefined && isNaN(longitude)) { - return fail(400, { message: 'Invalid longitude value.' }); - } - - let parsedUserPermissions: Array<{ userId: string; permissionLevelId: number }> = []; - try { - parsedUserPermissions = JSON.parse(userPermissionsData); - } catch (error) { - return fail(400, { message: 'Invalid format for user permissions data.' }); - } - - const supabase = locals.supabase as SupabaseClient; - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - - const deviceToInsert: DeviceInsert = { - dev_eui: devEui, - name: name, - lat: latitude, // Will be undefined if not provided, matching optional DB field - long: longitude, // Will be undefined if not provided, matching optional DB field - location_id: location_id, - user_id: currentUserId, // User who is creating the device - type: deviceType // Add device type from form - // upload_interval are omitted, assuming they are nullable or have DB defaults - }; - - try { - const createdDevice = await deviceService.createDevice(deviceToInsert); - if (!createdDevice) { - // This case might be handled by deviceService.createDevice throwing an error - return fail(500, { - message: 'Failed to create device record. The service returned no device.' - }); - } - - // Add current user as Admin owner in cw_device_owners - await deviceRepository.addUserToDevice( - createdDevice.dev_eui, - currentUserId, - PermissionLevel.Admin - ); - - // Add other users with specified permissions to cw_device_owners - for (const perm of parsedUserPermissions) { - if (perm.permissionLevelId !== PermissionLevel.Disabled) { - // Only add if not disabled - await deviceRepository.addUserToDevice( - createdDevice.dev_eui, - perm.userId, - perm.permissionLevelId - ); - } - } - - // Successfully created device and permissions - return { success: true, deviceId: createdDevice.dev_eui }; - } catch (error) { - console.error('Error creating device or setting permissions:', error); - // errorHandler.handleDatabaseError or a more specific error handler could be used - return fail(500, { - message: - 'Failed to create device or set permissions. ' + - (error instanceof Error ? error.message : 'Unknown error') - }); - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/create/+page.svelte deleted file mode 100644 index 25c806c4..00000000 --- a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.svelte +++ /dev/null @@ -1,203 +0,0 @@ - - - - {$_('Create a New Device')} - - -
    - -
    -
    -

    {$_('Create a New Device')}

    -

    - {$_('Create a new device to be used in the location {location}.', { - location: location?.name ?? 'err' - })} -

    -
    -
    - -
    - -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    - -
    - - - -
    -
    -
    - -
    -

    {$_('User Permissions')}

    -
    - {#each permissionTypes as [label, level]} - - {/each} -
    - - - - - {#each permissionTypes as [label]} - - {/each} - - - - {#each users as user, i} - - - {#each permissionTypes as [, level]} - - {/each} - - {/each} - -
    {$_('User')}{label}
    {user.profile.email} - updatePermission(i, level)} - /> -
    - -
    - -
    - -
    -
    diff --git a/src/routes/app/dashboard/location/[location_id]/settings/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/settings/+page.server.ts deleted file mode 100644 index f909c83b..00000000 --- a/src/routes/app/dashboard/location/[location_id]/settings/+page.server.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { error, redirect } from '@sveltejs/kit'; -import type { PageServerLoad, Actions } from './$types'; -import { PermissionLevel } from '$lib/models/LocationUser'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { AuthService } from '$lib/services/AuthService'; -import { LocationService } from '$lib/services/LocationService'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; - -export const load: PageServerLoad = async ({ params, locals: { supabase } }) => { - const locationId = parseInt(params.location_id, 10); - - if (isNaN(locationId)) { - throw error(400, 'Invalid location ID'); - } - - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(supabase, errorHandler); - const session = await authService.getSession(); - - if (!session || !session.user) { - throw redirect(302, '/auth/login'); - } - - const user = session.user; - - // Create a repository with the authenticated supabase client - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - // Create a service with the authenticated repository - const locationService = new LocationService(locationRepo, deviceRepo); - - // Get location details using the service with authenticated client - const location = await locationService.getLocationById(locationId); - const devices = await deviceRepo.findByLocation(locationId); - - if (!location) { - throw error(404, 'Location not found'); - } - - // Check if current user has admin permission for this location - const hasAdminPermission = await locationService.checkUserLocationPermission( - locationId, - user.id, - PermissionLevel.Admin - ); - - if (!hasAdminPermission) { - throw error(403, 'Only administrators can access location settings'); - } - - // Get all users with access to this location - const locationUsers = await locationService.getLocationUsers(locationId); - - return { - location, - locationUsers, - deviceCount: devices ? devices.length : 0, - currentUser: user - }; -}; - -export const actions: Actions = { - updateLocation: async ({ request, params, locals: { supabase } }) => { - const locationId = parseInt(params.location_id, 10); - - if (isNaN(locationId)) { - return { success: false, error: 'Invalid location ID' }; - } - - const formData = await request.formData(); - const name = formData.get('name') as string; - const description = formData.get('description') as string; - const latString = formData.get('lat') as string; - const longString = formData.get('long') as string; - - // Validate name is provided - if (!name || name.trim() === '') { - return { success: false, error: 'Location name is required' }; - } - - // Parse latitude and longitude - let lat: number | null = null; - let long: number | null = null; - - if (latString && latString.trim() !== '') { - lat = parseFloat(latString); - if (isNaN(lat) || lat < -90 || lat > 90) { - return { success: false, error: 'Invalid latitude value' }; - } - } - - if (longString && longString.trim() !== '') { - long = parseFloat(longString); - if (isNaN(long) || long < -180 || long > 180) { - return { success: false, error: 'Invalid longitude value' }; - } - } - - // Only set coordinates if both values are provided - if ((lat !== null && long === null) || (lat === null && long !== null)) { - return { - success: false, - error: 'Both latitude and longitude must be provided or left empty' - }; - } - - try { - const errorHandler = new ErrorHandlingService(); - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - // Update the location - const updatedLocation = await locationService.updateLocation(locationId, { - name, - description, - lat, - long - }); - - if (!updatedLocation) { - return { success: false, error: 'Failed to update location' }; - } - - return { success: true, data: updatedLocation }; - } catch (err) { - console.error('Error updating location:', err); - return { - success: false, - error: err instanceof Error ? err.message : 'An unexpected error occurred' - }; - } - }, - - addUser: async ({ request, params, locals: { supabase } }) => { - const formData = await request.formData(); - const email = formData.get('email') as string; - const permissionLevel = parseInt(formData.get('permissionLevel') as string, 10); - const applyToDevices = formData.get('applyToDevices') === 'true'; - const locationId = parseInt(params.location_id, 10); - - if (!email || isNaN(permissionLevel) || isNaN(locationId)) { - return { success: false, error: 'Invalid input data' }; - } - - const errorHandler = new ErrorHandlingService(); - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - const result = await locationService.addUserToLocation( - locationId, - email, - permissionLevel, - applyToDevices - ); - - return result; - }, - - updatePermission: async ({ request, params, locals: { supabase, safeGetSession } }) => { - const { session, user } = await safeGetSession(); - if (!session || !user) { - return { success: false, error: 'Authentication required' }; - } - - const formData = await request.formData(); - const userId = user.id; - const permissionLevel = parseInt(formData.get('permissionLevel') as string, 10); - const locationOwnerId = parseInt(formData.get('ownerId') as string, 10); - const applyToDevices = formData.get('applyToDevices') === 'true'; - const locationId = parseInt(params.location_id, 10); - - if (!userId || isNaN(permissionLevel) || isNaN(locationOwnerId) || isNaN(locationId)) { - return { success: false, error: 'Invalid input data' }; - } - - const errorHandler = new ErrorHandlingService(); - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - const result = await locationService.updateUserPermission( - locationId, - userId, - locationOwnerId, - permissionLevel, - applyToDevices - ); - - return result; - }, - - removeUser: async ({ request, params, locals: { supabase, safeGetSession } }) => { - const { session, user } = await safeGetSession(); - if (!session || !user) { - return { success: false, error: 'Authentication required' }; - } - const formData = await request.formData(); - const userId = formData.get('userId') as string; - const me = user.id; - const locationOwnerId = parseInt(params.location_id, 10); - const locationId = parseInt(params.location_id, 10); - - if (!userId || isNaN(locationOwnerId) || isNaN(locationId)) { - return { success: false, error: 'Invalid input data' }; - } - - const errorHandler = new ErrorHandlingService(); - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - const result = await locationService.removeUserFromLocation( - locationId, - userId, - locationOwnerId, - me - ); - - return result; - }, - - deleteLocation: async ({ params, locals: { supabase, safeGetSession } }) => { - const locationId = parseInt(params.location_id, 10); - - if (isNaN(locationId)) { - return { success: false, error: 'Invalid location ID' }; - } - - const { session, user } = await safeGetSession(); - if (!session || !user) { - return { success: false, error: 'Authentication required' }; - } - - const errorHandler = new ErrorHandlingService(); - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - try { - await locationService.deleteLocation(locationId, user.id); - return { success: true }; - } catch (err) { - console.error('Error deleting location:', err); - return { - success: false, - error: err instanceof Error ? err.message : 'An unexpected error occurred' - }; - } - } -}; diff --git a/src/routes/app/dashboard/location/[location_id]/settings/+page.svelte b/src/routes/app/dashboard/location/[location_id]/settings/+page.svelte deleted file mode 100644 index b09a3bcc..00000000 --- a/src/routes/app/dashboard/location/[location_id]/settings/+page.svelte +++ /dev/null @@ -1,425 +0,0 @@ - - - - Location Settings - {location.name || 'No name set'} - - -
    - -
    -
    -

    {$_('Location Settings')}

    -
    - -
    - -
    - -
    -
    -

    {$_('Location Details')}

    - {#if !editingLocation} - - {/if} -
    - - {#if editingLocation} -
    { - updatingLocation = true; - - return ({ result }) => { - handleLocationUpdateSuccess(result); - }; - }} - use:formValidation - > -
    - - -
    - -
    - - -
    - -
    - - -

    Decimal format (e.g. 51.5074)

    -
    - -
    - - -

    Decimal format (e.g. -0.1278)

    -
    - -
    - - -
    -
    - {:else} -
    -
    -

    {$_('Name')}

    -

    {location.name || $_('No name set')}

    -
    -
    -

    {$_('Description')}

    -

    {location.description}

    -
    -
    -

    {$_('Coordinates')}

    -

    - {#if location.lat && location.long} - {location.lat.toFixed(6)}, {location.long.toFixed(6)} - {:else} - {$_('Not set')} - {/if} -

    -
    -
    - {/if} -
    -
    - - -
    -
    -

    {$_('User Permissions')}

    -

    - {$_('manage_permissions_description')} -

    - - - -
    -

    {$_('Add New User')}

    - -
    { - addingUser = true; - - return ({ result }) => { - handleAddUserSuccess(result); - }; - }} - use:formValidation - > -
    - - -
    - -
    - - -

    - {$_('Admin:')} - {$_('Full control over location and devices')}
    - {$_('User:')} - {$_('Can use and configure devices')}
    - {$_('Viewer:')} - {$_('Can only view data')}
    - {$_('Disabled:')} - {$_('No access')} -

    -
    - -
    - -

    - {$_('If unchecked, user will be added with "Disabled" permission to all devices.')} -

    -
    - -
    - -
    -
    -
    -
    - -
    -

    {$_('Dangerous Zone')}

    -
    { - return ({ result }: { result: ActionResult }) => { - if (result.type === 'success' && result.data?.success) { - successToast($_('Location removed successfully')); - window.location.href = '/app/dashboard/location/'; - } else { - // @ts-ignore - errorToast(result.data?.error || $_('Failed to remove location')); - } - }; - }} - use:formValidation - > - -
    -
    -
    -
    -
    - - - {#snippet title()} - {$_('Remove Location')} - {/snippet} - {#snippet body()} - {$_('Are you sure you want to remove this location? This action cannot be undone.')} - {/snippet} - {#snippet footer()} - - - {/snippet} - diff --git a/src/routes/app/dashboard/location/create/+page.server.ts b/src/routes/app/dashboard/location/create/+page.server.ts deleted file mode 100644 index 6e3f896c..00000000 --- a/src/routes/app/dashboard/location/create/+page.server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { SessionService } from '$lib/services/SessionService'; -import { LocationRepository } from '$lib/repositories/LocationRepository'; -import { DeviceRepository } from '$lib/repositories/DeviceRepository'; -import { LocationService } from '$lib/services/LocationService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; -import { AuthService } from '$lib/services/AuthService'; -import type { LocationInsert } from '$lib/models/Location'; - -export const load: PageServerLoad = async ({ locals }) => { - // Create a new SessionService instance with the per-request Supabase client - const sessionService = new SessionService(locals.supabase); - const sessionResult = await sessionService.getSafeSession(); - - // If no session exists, redirect to login - if (!sessionResult || !sessionResult.user) { - throw redirect(302, '/auth/login'); - } - return {}; -}; - - -export const actions: Actions = { - createLocation: async ({ request, params, locals: { supabase } }) => { - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(supabase, errorHandler); - const session = await authService.getSession(); - - if (!session || !session.user) { - throw redirect(302, '/auth/login'); - } - - const user = session.user; - const formData = await request.formData(); - const name = formData.get('name') as string; - const description = formData.get('description') as string; - const latString = formData.get('lat') as string; - const longString = formData.get('long') as string; - - try { - const locationRepo = new LocationRepository(supabase, errorHandler); - const deviceRepo = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepo, deviceRepo); - - const location: LocationInsert = { - name, - description, - lat: parseFloat(latString), - long: parseFloat(longString), - owner_id: user.id - }; - // Update the location - const updatedLocation = await locationService.createLocation(location); - - if (!updatedLocation) { - return { success: false, error: 'Failed to create location' }; - } - - return { success: true, data: updatedLocation }; - } catch (err) { - console.error('Error creating location:', err); - return { - success: false, - error: err instanceof Error ? err.message : 'An unexpected error occurred' - }; - } - }, -}; \ No newline at end of file diff --git a/src/routes/app/dashboard/location/create/+page.svelte b/src/routes/app/dashboard/location/create/+page.svelte deleted file mode 100644 index 28677a13..00000000 --- a/src/routes/app/dashboard/location/create/+page.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -
    -
    -

    - {$_('Create Location')} -

    -

    - {$_('Add a new location to your account.')} -

    -
    - -
    -
    -
    -
    - - -
    - -
    - - -

    - {$_('Pick a point on the map or adjust the values manually.')} -

    -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - - {#if geolocationAvailable} -
    - -

    - {$_('We use your browser location to prefill coordinates.')} -

    -
    - {:else} -

    - {$_('Browser location is unavailable; enter coordinates manually.')} -

    - {/if} -
    -
    - -
    -
    -

    - {$_('Location Preview')} -

    -
    -

    - {$_('Click the map to fine-tune the coordinates.')} -

    -
    - {#if browser} - - {/if} -
    -
    -
    - -
    - -
    -
    -
    diff --git a/src/routes/auth/+layout.svelte b/src/routes/auth/+layout.svelte index 13ce5c22..e88c077e 100644 --- a/src/routes/auth/+layout.svelte +++ b/src/routes/auth/+layout.svelte @@ -1,116 +1,200 @@ + import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; -{@render children?.()} + let { children } = $props(); - diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts deleted file mode 100644 index 5b6f1698..00000000 --- a/src/routes/auth/callback/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -export const GET = async (event) => { - const { - url, - locals: { supabase } - } = event; - const code = url.searchParams.get('code') as string; - const next = url.searchParams.get('next') ?? '/app/dashboard'; - if (code) { - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (!error) { - throw redirect(303, `/${next.slice(1)}`); - } - } - // return the user to an error page with instructions - throw redirect(303, '/auth/auth-code-error'); -}; diff --git a/src/routes/auth/check-email/+page.svelte b/src/routes/auth/check-email/+page.svelte deleted file mode 100644 index 268782f5..00000000 --- a/src/routes/auth/check-email/+page.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - - - {$_('Check Your Email')} | IoT Dashboard - - - -
    - -
    -
    -
    -
    - - - -
    - -

    - {$_('Check your email')} -

    - -
    - {#if email} -

    - {$_("We've sent a verification link to")} {email} -

    - {:else} -

    {$_("We've sent a verification link to your email address")}

    - {/if} -

    - {$_('Click the link in the email to verify your account and complete your registration.')} -

    -
    - -
    -

    - ⚠️ {$_('Important')}: - {$_('Please open the verification link on this same device you used to register.')} -

    -
    - -
    -
    -

    {$_("Didn't receive an email?")}

    -

    - {$_('Check your spam folder or')} - -

    -
    - -
    - -
    -
    -
    -
    -
    - - diff --git a/src/routes/auth/confirm/+server.ts b/src/routes/auth/confirm/+server.ts deleted file mode 100644 index 35991119..00000000 --- a/src/routes/auth/confirm/+server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { redirect } from '@sveltejs/kit' -import { type EmailOtpType } from '@supabase/supabase-js' - -export const GET = async (event) => { - const { - url, - locals: { supabase }, - } = event - const token_hash = url.searchParams.get('token_hash') as string - const type = url.searchParams.get('type') as EmailOtpType | null - const next = url.searchParams.get('next') ?? '/' - - /** - * Clean up the redirect URL by deleting the Auth flow parameters. - * - * `next` is preserved for now, because it's needed in the error case. - */ - const redirectTo = new URL(url) - redirectTo.pathname = next - redirectTo.searchParams.delete('token_hash') - redirectTo.searchParams.delete('type') - - if (token_hash && type) { - const { error } = await supabase.auth.verifyOtp({ token_hash, type }) - if (!error) { - redirectTo.searchParams.delete('next') - redirect(303, redirectTo) - } - } - - // return the user to an error page with some instructions - redirectTo.pathname = '/auth/error' - redirect(303, redirectTo) -} \ No newline at end of file diff --git a/src/routes/auth/create-account/+page.server.ts b/src/routes/auth/create-account/+page.server.ts new file mode 100644 index 00000000..124c89f4 --- /dev/null +++ b/src/routes/auth/create-account/+page.server.ts @@ -0,0 +1,128 @@ +import { m } from '$lib/paraglide/messages.js'; +import { readRedirectPathFromUrl, withRedirectParam } from '$lib/utils/auth-redirect'; +import { verifyRecaptchaToken } from '$lib/utils/recaptcha.server'; +import { getSupabaseClient } from '$lib/supabase.server'; +import { fail, redirect, type Actions } from '@sveltejs/kit'; + +function readNonEmptyString(value: FormDataEntryValue | null): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export const actions: Actions = { + default: async ({ request, url }) => { + const data = await request.formData(); + const redirectPath = readRedirectPathFromUrl(url, ''); + + const firstName = readNonEmptyString(data.get('firstName')); + const lastName = readNonEmptyString(data.get('lastName')); + const email = readNonEmptyString(data.get('email')); + const password = readNonEmptyString(data.get('password')); + const confirmPassword = readNonEmptyString(data.get('confirmPassword')); + const company = readNonEmptyString(data.get('company')); + const recaptchaToken = readNonEmptyString(data.get('recaptchaToken')); + + const agreedTerms = data.get('agreedTerms') === 'true'; + const agreedPrivacy = data.get('agreedPrivacy') === 'true'; + const agreedCookies = data.get('agreedCookies') === 'true'; + + // ── Validation ───────────────────────────────────────────── + if (!firstName || !lastName || !email || !password || !confirmPassword || !company) { + return fail(400, { + message: m.auth_all_fields_required(), + firstName, + lastName, + email, + company + }); + } + + if (!recaptchaToken) { + return fail(400, { + message: m.auth_security_try_again(), + firstName, + lastName, + email, + company + }); + } + + if (password !== confirmPassword) { + return fail(400, { + message: m.auth_passwords_do_not_match_plain(), + firstName, + lastName, + email, + company + }); + } + + if (!agreedTerms || !agreedPrivacy || !agreedCookies) { + return fail(400, { + message: m.auth_must_agree_all_policies(), + firstName, + lastName, + email, + company + }); + } + + // ── reCAPTCHA verification ───────────────────────────────── + const recaptchaResult = await verifyRecaptchaToken(recaptchaToken, 'REGISTER', 0.5, { + route: '/auth/create-account', + flow: 'register', + userAgent: request.headers.get('user-agent') ?? undefined + }); + if (!recaptchaResult.success) { + return fail(400, { + message: m.auth_security_try_again(), + firstName, + lastName, + email, + company + }); + } + + // ── Supabase sign-up (anon key — respects RLS) ───────────── + const supabase = getSupabaseClient(); + + const { data: signUpData, error: signUpError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + first_name: firstName, + last_name: lastName, + company, + agreed_terms: true, + agreed_privacy: true, + agreed_cookies: true + } + } + }); + + if (signUpError) { + console.error('Supabase sign-up error:', signUpError); + + const msg = signUpError.message?.toLowerCase() ?? ''; + const userFacingMessage = + msg.includes('already been registered') || + msg.includes('already registered') || + msg.includes('duplicate') + ? m.auth_account_exists() + : m.auth_registration_failed(); + + return fail(signUpError.status ?? 500, { + message: userFacingMessage, + firstName, + lastName, + email, + company + }); + } + + // Success – send user to the "check your email" page + throw redirect(303, withRedirectParam('/auth/create-account/check-email', redirectPath)); + } +}; diff --git a/src/routes/auth/create-account/+page.svelte b/src/routes/auth/create-account/+page.svelte new file mode 100644 index 00000000..1b570fb7 --- /dev/null +++ b/src/routes/auth/create-account/+page.svelte @@ -0,0 +1,479 @@ + + + + {m.auth_create_account_page_title()} + + + +
    +
    + {m.app_name()} +
    + +

    {m.auth_create_account()}

    +

    + {m.auth_or_prefix()} + + + {m.auth_sign_in_existing_account()} + +

    + +
    { + if (submitting) { + cancel(); + return; + } + submitting = true; + + try { + formData.set('recaptchaToken', await recaptcha.runAction('REGISTER')); + } catch (error) { + console.error('reCAPTCHA execution error:', error); + toast.add({ + message: m.auth_security_try_again(), + tone: 'danger' + }); + submitting = false; + cancel(); + return; + } + + // Inject consent flags (hidden inputs would work too but this is cleaner) + formData.set('agreedTerms', String(agreedTerms)); + formData.set('agreedPrivacy', String(agreedPrivacy)); + formData.set('agreedCookies', String(agreedCookies)); + + return async ({ result }) => { + try { + if (result.type === 'failure' && typeof result.data?.message === 'string') { + firstName = typeof result.data.firstName === 'string' ? result.data.firstName : ''; + lastName = typeof result.data.lastName === 'string' ? result.data.lastName : ''; + email = typeof result.data.email === 'string' ? result.data.email : ''; + company = typeof result.data.company === 'string' ? result.data.company : ''; + + toast.add({ + message: result.data.message, + tone: 'danger' + }); + } + + await applyAction(result); + } finally { + submitting = false; + } + }; + }} + > + +
    + + + +
    + + + + + + + + + {#if password.length > 0} +
      +
    • + {passwordValidation.hasMinLength ? '✓' : '○'} + {m.auth_password_requirement_length()} +
    • +
    • + {passwordValidation.hasLowerCase ? '✓' : '○'} + {m.auth_password_requirement_lowercase()} +
    • +
    • + {passwordValidation.hasUpperCase ? '✓' : '○'} + {m.auth_password_requirement_uppercase()} +
    • +
    • + {passwordValidation.hasNumber ? '✓' : '○'} + {m.auth_password_requirement_number()} +
    • +
    • + {passwordValidation.hasSymbol ? '✓' : '○'} + {m.auth_password_requirement_symbol()} +
    • +
    + {/if} + + + + + {#if confirmPassword.length > 0} +

    + {passwordsMatch ? m.auth_passwords_match() : m.auth_passwords_do_not_match()} +

    + {/if} + + + + + + + + + + + {submitting ? m.auth_creating_account() : m.auth_create_account()} + + + + + + {m.auth_sign_in_instead()} + +
    + +

    {m.auth_security_copy()}

    +
    +
    + + diff --git a/src/routes/auth/create-account/check-email/+page.svelte b/src/routes/auth/create-account/check-email/+page.svelte new file mode 100644 index 00000000..d96cd15d --- /dev/null +++ b/src/routes/auth/create-account/check-email/+page.svelte @@ -0,0 +1,206 @@ + + + + {m.auth_check_email_page_title()} + + + +
    +
    + {m.app_name()} +
    + + + + +

    {m.auth_check_email_heading()}

    + +
    +

    + {m.auth_check_email_account_created()} +

    +

    + {m.auth_check_email_activation_link_prefix()} + {m.auth_check_email_activation_link_highlight()} + {m.auth_check_email_activation_link_suffix()} +

    +

    + {m.auth_check_email_open_inbox_prefix()} + {m.auth_check_email_open_inbox_highlight()} + {m.auth_check_email_open_inbox_suffix()} +

    +
    + +
    +

    {m.auth_check_email_missing_heading()}

    +
      +
    • {m.auth_check_email_tip_spam()}
    • +
    • {m.auth_check_email_tip_address()}
    • +
    • {m.auth_check_email_tip_delivery()}
    • +
    +
    +
    + + {m.auth_check_email_you()} +
    + +
    + + {m.auth_check_email_check()} +
    + +
    + + {m.auth_check_email_email()} +
    +
    +

    {m.auth_check_email_cant_find_it()}

    +
    +
    + + {m.auth_check_email_you()} +
    + +
    + + {m.auth_check_email_check()} +
    + +
    + + {m.auth_check_email_spam()} +
    +
    +

    {m.auth_check_email_not_there()}

    + {m.auth_check_email_contact_support()} +
    + + + + + {m.auth_go_to_sign_in()} + + +

    {m.auth_security_copy()}

    +
    +
    + + diff --git a/src/routes/auth/error/+page.svelte b/src/routes/auth/error/+page.svelte deleted file mode 100644 index 9253eb83..00000000 --- a/src/routes/auth/error/+page.svelte +++ /dev/null @@ -1,133 +0,0 @@ - - - - {$_('Authentication Error')} | CropWatch - - - -
    - -
    -
    -
    -
    - - - -
    - -

    {$_('Authentication Error')}

    - -

    - {$_( - 'There was a problem with your authentication request. Please try signing in again on the login page. If you continue to experience issues, please contact our support team for additional help.' - )} -

    - -
    - - - -
    -
    -
    -
    - - diff --git a/src/routes/auth/forgot-password/+page.server.ts b/src/routes/auth/forgot-password/+page.server.ts index 47308735..c227dcd4 100644 --- a/src/routes/auth/forgot-password/+page.server.ts +++ b/src/routes/auth/forgot-password/+page.server.ts @@ -1,69 +1,54 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -// Check if user is already logged in, redirect them to dashboard if they are -export const load: PageServerLoad = async ({ locals }) => { - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); - const user = await authService.getSession(); - if (user) { - throw redirect(303, '/dashboard'); - } - return {}; -}; +import { m } from '$lib/paraglide/messages.js'; +import { buildLoginPath, readRedirectPathFromUrl } from '$lib/utils/auth-redirect'; +import { verifyRecaptchaToken } from '$lib/utils/recaptcha.server'; +import { getSupabaseClient } from '$lib/supabase.server'; +import { fail, type Actions } from '@sveltejs/kit'; + +function readNonEmptyString(value: FormDataEntryValue | null): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} export const actions: Actions = { - default: async ({ request, locals }) => { - try { - // Parse form data - const formData = await request.formData(); - const email = formData.get('email')?.toString().trim() || ''; - - // Validate email - if (!email) { - return fail(400, { error: 'Email is required', email: '' }); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return fail(400, { error: 'Invalid email format', email }); - } - - // Get services - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); + forgotPassword: async ({ request, url }) => { + const data = await request.formData(); + const redirectPath = readRedirectPathFromUrl(url, ''); + const email = readNonEmptyString(data.get('email')); + const recaptchaToken = readNonEmptyString(data.get('recaptchaToken')); + + if (!email) { + return fail(400, { message: m.auth_enter_email() }); + } - // Request password reset - const result = await authService.resetPassword(email); + if (!recaptchaToken) { + return fail(400, { message: m.auth_security_try_again() }); + } - if (!result.success) { - console.error('Password reset error:', result.error); + const recaptchaResult = await verifyRecaptchaToken(recaptchaToken, 'FORGOT_PASSWORD', 0.5, { + route: '/auth/forgot-password', + flow: 'forgot-password', + userAgent: request.headers.get('user-agent') ?? undefined + }); + if (!recaptchaResult.success) { + return fail(400, { message: m.auth_security_try_again() }); + } - // Don't expose specific errors to prevent email enumeration - // Always show success to avoid revealing which emails are registered - return { - success: result.success, - error: result.error, - email - }; - } + const supabase = getSupabaseClient(); - // Return success without exposing if email exists in system - return { - success: true, - email - }; - } catch (error) { - console.error('Password reset request error:', error); + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${url.origin}${buildLoginPath({ + redirectTo: redirectPath, + reason: 'password-reset' + })}` + }); - // Generic error message - return fail(500, { - error: 'An error occurred. Please try again later.', - email: '' - }); + if (error) { + console.error('Supabase resetPasswordForEmail error:', error); + return fail(500, { message: m.auth_login_failed() }); } + + // Always return success to avoid email enumeration + return { success: true }; } }; diff --git a/src/routes/auth/forgot-password/+page.svelte b/src/routes/auth/forgot-password/+page.svelte index 390b7fe1..17e5edbf 100644 --- a/src/routes/auth/forgot-password/+page.svelte +++ b/src/routes/auth/forgot-password/+page.svelte @@ -1,185 +1,345 @@ - {$_('Forgot Password')} | CropWatch + {m.auth_forgot_password_page_title()} - -
    + +
    +
    + {m.app_name()} +
    -
    -
    -

    {$_('Reset Password')}

    + {#if sent} +

    {m.auth_check_email_heading()}

    +

    {m.auth_forgot_password_sent_body()}

    - {#if form?.success} - + + {m.auth_back_to_login()} + {:else} -

    - {$_("Enter your email address and we'll send you a link to reset your password.")} -

    - -
    -
    - - {m.auth_forgot_password()} +

    {m.auth_forgot_password_subtitle()}

    + + { + if (submitting) { + cancel(); + return; + } + submitting = true; + + try { + formData.set('recaptchaToken', await recaptcha.runAction('FORGOT_PASSWORD')); + } catch (err) { + console.error('reCAPTCHA token failed:', err); + toast.add({ message: m.auth_security_try_again(), tone: 'danger' }); + submitting = false; + cancel(); + return; + } + + return async ({ result }) => { + if (result.type === 'failure' && typeof result.data?.message === 'string') { + toast.add({ + message: result.data.message, + tone: 'danger' + }); + } + + submitting = false; + await applyAction(result); + }; + }} + > +
    + + + + {#if submitting} + {m.auth_sending()} + {:else} + + {m.auth_send_reset_link()} + {/if} + - {#if form?.error} -
    -
    + diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index 131b1aa3..633efb42 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -1,78 +1,19 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; +import { env as publicEnv } from '$env/dynamic/public'; +import { m } from '$lib/paraglide/messages.js'; +import { handleLoginAction } from '$lib/server/auth/login-action'; +import { verifyRecaptchaToken } from '$lib/utils/recaptcha.server'; +import type { Actions } from '@sveltejs/kit'; -export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { - // Don't return the supabase client - it's not serializable - // The client is available in the browser through other means - return {}; -}; +const PUBLIC_API_BASE_URL = (publicEnv.PUBLIC_API_BASE_URL ?? '').trim(); +const PUBLIC_LOGIN_ENDPOINT = publicEnv.PUBLIC_LOGIN_ENDPOINT ?? '/auth/login'; export const actions: Actions = { - update: async ({ request, locals: { supabase, safeGetSession } }) => { - const formData = await request.formData(); - const fullName = formData.get('fullName'); - const username = formData.get('username'); - const website = formData.get('website'); - const avatarUrl = formData.get('avatarUrl'); - const { session } = await safeGetSession(); - - if (!session?.user) { - return fail(401, { error: 'Authentication required' }); - } - - if ( - typeof fullName !== 'string' || - typeof username !== 'string' || - typeof website !== 'string' || - typeof avatarUrl !== 'string' - ) { - return fail(400, { error: 'Invalid form submission' }); - } - - const payload = { - id: session.user.id, - full_name: fullName, - username, - website, - avatar_url: avatarUrl, - updated_at: new Date().toISOString() - }; - - const { error } = await supabase.from('profiles').upsert([payload]); - if (error) { - return fail(500, { - fullName, - username, - website, - avatarUrl - }); - } - return { - fullName, - username, - website, - avatarUrl - }; - }, - signout: async ({ locals: { supabase }, cookies }) => { - try { - // Sign out from Supabase with local scope to only affect current browser - await supabase.auth.signOut({ - scope: 'local' - }); - - // Clear Supabase auth cookies - const authCookies = cookies.getAll(); - for (const cookie of authCookies) { - if (cookie.name.includes('supabase') || cookie.name.includes('auth')) { - cookies.delete(cookie.name, { path: '/' }); - } - } - } catch (error) { - console.error('Signout error:', error); - } - - // Always redirect to home page after signout attempt - redirect(303, '/'); - } + default: (event) => + handleLoginAction(event, { + publicApiBaseUrl: PUBLIC_API_BASE_URL, + publicLoginEndpoint: PUBLIC_LOGIN_ENDPOINT, + secureCookies: event.url.protocol === 'https:', + verifyRecaptchaToken, + messages: m + }) }; diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 198a755f..95cf5727 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -1,264 +1,176 @@ - {$_('Login')} | {$_('IoT Dashboard')} + {m.auth_login_page_title()} -
    - -
    + +
    +
    + {m.app_name()} +
    - -
    -
    {m.auth_login_heading()} +

    {m.auth_login_subtitle()}

    + +
    { + if (loggingIn) { + cancel(); + return; + } + + loggingIn = true; + + try { + formData.set('recaptchaToken', await recaptcha.runAction('LOGIN')); + } catch (error) { + console.error('reCAPTCHA execution error:', error); + toast.add({ + message: m.auth_security_try_again(), + tone: 'danger', + duration: 7000 + }); + loggingIn = false; + cancel(); + return; + } + + return async ({ result }) => { + try { + if (result.type === 'failure' && typeof result.data?.message === 'string') { + toast.add({ + message: result.data.message, + tone: 'danger' + }); + } + + if (result.type === 'redirect') { + toast.add({ + message: m.auth_login_success_redirecting(), + tone: 'success' + }); + } + + await applyAction(result); + } finally { + loggingIn = false; + } + }; + }} > -

    {$_('Login')}

    - - - - {#if successMessage} -
    - {/if} -
    +

    {m.auth_security_copy()}

    -
    +
    diff --git a/src/routes/auth/login/DiscordAuthLogin.svelte b/src/routes/auth/login/DiscordAuthLogin.svelte deleted file mode 100644 index 7a5e337b..00000000 --- a/src/routes/auth/login/DiscordAuthLogin.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - diff --git a/src/routes/auth/login/GoogleAuthLogin.svelte b/src/routes/auth/login/GoogleAuthLogin.svelte deleted file mode 100644 index 03df2f44..00000000 --- a/src/routes/auth/login/GoogleAuthLogin.svelte +++ /dev/null @@ -1,185 +0,0 @@ - - - - - diff --git a/src/routes/auth/login/login-background-images/beach.jpg b/src/routes/auth/login/login-background-images/beach.jpg deleted file mode 100644 index c7a567be..00000000 Binary files a/src/routes/auth/login/login-background-images/beach.jpg and /dev/null differ diff --git a/src/routes/auth/login/login-background-images/field1.jpg b/src/routes/auth/login/login-background-images/field1.jpg deleted file mode 100644 index 54cd2ffa..00000000 Binary files a/src/routes/auth/login/login-background-images/field1.jpg and /dev/null differ diff --git a/src/routes/auth/login/login-background-images/field2.jpg b/src/routes/auth/login/login-background-images/field2.jpg deleted file mode 100644 index 33303ff0..00000000 Binary files a/src/routes/auth/login/login-background-images/field2.jpg and /dev/null differ diff --git a/src/routes/auth/login/login-background-images/greenhouse.jpg b/src/routes/auth/login/login-background-images/greenhouse.jpg deleted file mode 100644 index 0ed92e99..00000000 Binary files a/src/routes/auth/login/login-background-images/greenhouse.jpg and /dev/null differ diff --git a/src/routes/auth/login/style.css b/src/routes/auth/login/style.css new file mode 100644 index 00000000..36d4b1c1 --- /dev/null +++ b/src/routes/auth/login/style.css @@ -0,0 +1,200 @@ +:global(.auth-card.cw-card) { + width: 100%; + border-radius: 1rem; + border-color: rgb(65 91 136 / 60%); + background: linear-gradient(180deg, rgb(11 27 62 / 95%) 0%, rgb(11 25 58 / 93%) 100%); + box-shadow: 0 34px 90px rgb(2 8 24 / 70%); + overflow: hidden; +} + +:global(.auth-card .cw-card__body) { + padding: 0; +} + +.auth-shell { + padding: 1.8rem 1.4rem 1rem; +} + +.logo-frame { + display: grid; + height: 3.95rem; + width: 3.95rem; + place-items: center; + margin: 0 auto 1.35rem; + border-radius: 0.9rem; + border: 1px solid rgb(61 88 130 / 56%); + background: linear-gradient(180deg, rgb(19 42 85 / 90%), rgb(15 33 71 / 88%)); + box-shadow: inset 0 1px 0 rgb(128 163 214 / 15%); +} + +.logo-image { + height: 2rem; + width: 2rem; +} + +.auth-title { + margin: 0; + text-align: center; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.01em; + color: rgb(239 245 255); +} + +.auth-subtitle { + margin: 0.45rem 0 1.3rem; + text-align: center; + font-size: 1rem; + color: rgb(158 176 205); +} + +.auth-alert { + margin: 0 0 1rem; + border: 1px solid rgb(251 191 36 / 45%); + border-radius: 0.75rem; + background: rgb(251 191 36 / 12%); + padding: 0.6rem 0.8rem; + font-size: 0.88rem; + color: rgb(255 239 196); +} + +.auth-form { + display: grid; + gap: 0.82rem; +} + +.field-block { + display: grid; + gap: 0.42rem; +} + +.field-label { + font-size: 0.84rem; + font-weight: 500; + letter-spacing: 0.06em; + color: rgb(151 171 201); +} + +:global(.auth-input .cw-input__field) { + min-height: 3rem; + border-color: rgb(58 84 126 / 80%); + border-radius: 0.8rem; + background: rgb(23 41 79 / 78%); + padding: 0.78rem 0.9rem; + font-size: 1.06rem; + color: rgb(223 236 255); +} + +:global(.auth-input .cw-input__field::placeholder) { + color: rgb(140 162 196); +} + +:global(.auth-input .cw-input__field:focus) { + border-color: rgb(86 140 214 / 90%); + box-shadow: 0 0 0 2px rgb(70 137 220 / 26%); +} + +:global(.auth-primary.cw-button) { + min-height: 3rem; + border-radius: 0.8rem; + border-color: rgb(83 149 213 / 70%); + background: linear-gradient(180deg, #3889cb 0%, #2f74b3 100%); + color: rgb(242 248 255); + font-size: 1.12rem; + font-weight: 500; +} + +:global(.auth-primary.cw-button:hover:not(:disabled)) { + border-color: rgb(108 167 228 / 76%); + background: linear-gradient(180deg, #4395d8 0%, #347fbe 100%); +} + +.auth-button-link { + display: inline-flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 0.5rem; + box-sizing: border-box; + border: 1px solid transparent; + text-decoration: none; + line-height: 1; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease; +} + +.auth-button-link:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgb(70 137 220 / 26%); +} + +.auth-button-link--primary { + min-height: 3rem; + border-radius: 0.8rem; + border-color: rgb(83 149 213 / 70%); + background: linear-gradient(180deg, #3889cb 0%, #2f74b3 100%); + color: rgb(242 248 255); + font-size: 1.12rem; + font-weight: 500; +} + +.auth-button-link--primary:hover { + border-color: rgb(108 167 228 / 76%); + background: linear-gradient(180deg, #4395d8 0%, #347fbe 100%); +} + +.action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +:global(.auth-secondary.cw-button) { + min-height: 2.48rem; + border-radius: 0.75rem; + border-color: rgb(72 96 136 / 80%); + background: rgb(33 53 90 / 84%); + color: rgb(218 229 245); + font-size: 1rem; + font-weight: 450; +} + +:global(.auth-secondary.cw-button:hover:not(:disabled)) { + border-color: rgb(95 126 174 / 85%); + background: rgb(39 62 102 / 88%); +} + +.auth-button-link--secondary { + min-height: 2.48rem; + border-radius: 0.75rem; + border-color: rgb(72 96 136 / 80%); + background: rgb(33 53 90 / 84%); + color: rgb(218 229 245); + font-size: 1rem; + font-weight: 450; +} + +.auth-button-link--secondary:hover { + border-color: rgb(95 126 174 / 85%); + background: rgb(39 62 102 / 88%); +} + +.security-copy { + margin: 1.25rem 0 0; + text-align: center; + font-size: 0.84rem; + color: rgb(135 155 187); +} + +@media (max-width: 420px) { + .auth-shell { + padding: 1.45rem 1rem 0.85rem; + } + + .action-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/routes/auth/logout/+page.server.ts b/src/routes/auth/logout/+page.server.ts new file mode 100644 index 00000000..b3ad5e9d --- /dev/null +++ b/src/routes/auth/logout/+page.server.ts @@ -0,0 +1,19 @@ +import { buildLoginPath, readRedirectPathFromUrl } from '$lib/utils/auth-redirect'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ cookies, url }) => { + const redirectPath = readRedirectPathFromUrl(url, ''); + + cookies.delete('jwt', { path: '/' }); + for (const cookieName of cookies.getAll().map((cookie) => cookie.name)) { + cookies.delete(cookieName, { path: '/' }); + } + + throw redirect( + 303, + buildLoginPath({ + redirectTo: redirectPath, + reason: 'signed-out' + }) + ); +}; diff --git a/src/routes/auth/logout/+page.svelte b/src/routes/auth/logout/+page.svelte new file mode 100644 index 00000000..62ec8fe6 --- /dev/null +++ b/src/routes/auth/logout/+page.svelte @@ -0,0 +1 @@ +Cleaning things up, one moment! \ No newline at end of file diff --git a/src/routes/auth/register/+page.server.ts b/src/routes/auth/register/+page.server.ts deleted file mode 100644 index ba49b908..00000000 --- a/src/routes/auth/register/+page.server.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -const passwordRequirementPatterns = { - lowercase: /[a-z]/, - uppercase: /[A-Z]/, - number: /\d/, - symbol: /[^A-Za-z0-9]/ -}; - -function meetsPasswordRequirements(password: string): boolean { - return ( - password.length >= 8 && - passwordRequirementPatterns.lowercase.test(password) && - passwordRequirementPatterns.uppercase.test(password) && - passwordRequirementPatterns.number.test(password) && - passwordRequirementPatterns.symbol.test(password) - ); -} - -export const actions: Actions = { - register: async ({ request, locals }) => { - const data = await request.formData(); - - // Get form values - const firstName = data.get('firstName')?.toString() || ''; - const lastName = data.get('lastName')?.toString() || ''; - const email = data.get('email')?.toString() || ''; - const password = data.get('password')?.toString() || ''; - const confirmPassword = data.get('confirmPassword')?.toString() || ''; - const company = data.get('company')?.toString() || ''; - const agreedToTerms = data.get('terms') === 'on'; - const agreedToPrivacy = data.get('privacy') === 'on'; - const agreedToCookies = data.get('cookies') === 'on'; - - // Server-side validation - const errors: Record = {}; - - if (!firstName.trim()) { - errors.firstName = 'First name is required'; - } - - if (!lastName.trim()) { - errors.lastName = 'Last name is required'; - } - - if (!email.trim()) { - errors.email = 'Email is required'; - } else { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - errors.email = 'Please enter a valid email address'; - } - } - - if (!password) { - errors.password = 'Password is required'; - } else if (!meetsPasswordRequirements(password)) { - errors.password = - 'Password must be at least 8 characters and include a lowercase letter, uppercase letter, number, and symbol'; - } - - if (!confirmPassword) { - errors.confirmPassword = 'Please confirm your password'; - } else if (password !== confirmPassword) { - errors.confirmPassword = 'Passwords do not match'; - } - - if (!company.trim()) { - errors.company = 'Company name is required'; - } - - if (!agreedToTerms || !agreedToPrivacy || !agreedToCookies) { - errors.terms = 'You must agree to all terms and policies'; - } - - // If there are validation errors, return them - if (Object.keys(errors).length > 0) { - return fail(400, { - errors, - firstName, - lastName, - email, - company, - agreedToTerms, - agreedToPrivacy, - agreedToCookies - }); - } - - try { - // Get error handler - const errorHandler = new ErrorHandlingService(); - - // Create AuthService with the request's Supabase client - const authService = new AuthService(locals.supabase, errorHandler); - - // Register the user - const result = await authService.register({ - email, - password, - firstName, - lastName, - company - }); - - if (!result.success) { - // Registration failed - return fail(400, { - message: result.error || 'Registration failed', - errors: {}, - firstName, - lastName, - email, - company, - agreedToTerms, - agreedToPrivacy, - agreedToCookies - }); - } - - // Check if email verification is needed - if (!result.emailConfirmationRequired) { - // Redirect to check-email page with email parameter - throw redirect(303, `/auth/check-email?email=${encodeURIComponent(email)}`); - } else { - // No email confirmation needed, redirect to login - throw redirect(303, '/auth/login?registered=true'); - } - } catch (err) { - console.error('Registration error:', err); - - // If this is a redirect, let it pass through - if (err instanceof Response && err.status === 303) { - throw err; - } - - return fail(500, { - message: 'An unexpected error occurred during registration', - errors: {}, - firstName, - lastName, - email, - company, - agreedToTerms, - agreedToPrivacy, - agreedToCookies - }); - } - } -}; diff --git a/src/routes/auth/register/+page.svelte b/src/routes/auth/register/+page.svelte deleted file mode 100644 index 8e390e00..00000000 --- a/src/routes/auth/register/+page.svelte +++ /dev/null @@ -1,809 +0,0 @@ - - -{#snippet passwordRequirementList({ - value, - includeMatch -}: { - value: string; - includeMatch: boolean; -})} - {@const status = includeMatch ? null : computePasswordRequirementStatus(value)} - {@const unmetRequirementLabels = status ? getUnmetRequirementLabels(status) : []} -
      - {#if !includeMatch} - {#each passwordRequirementEntries as { key, label }} - {@const satisfied = status ? status[key] : false} -
    • - - {$_(label)} -
    • - {/each} - {/if} - - {#if includeMatch} -
    • - - - {#if passwordsMatch} - {$_('Passwords match')} - {:else} - {$_('Passwords must match')} - {/if} - -
    • - {/if} -
    - - {@const unmetWithMatch = (() => { - const messages = []; - const labels = - !includeMatch && Array.isArray(unmetRequirementLabels) ? unmetRequirementLabels : []; - - for (const label of labels) { - messages.push($_(label)); - } - - if (includeMatch && !passwordsMatch) { - messages.push($_('Passwords must match')); - } - - return messages; - })()} - - {#if unmetWithMatch.length > 0} -

    - {$_('Still needed')}: {unmetWithMatch.join(', ')} -

    - {/if} -{/snippet} - - - {$_('Register')} | IoT Dashboard - - - -
    - -
    -
    -
    -

    - {$_('Create your account')} -

    -

    - {$_('Or')} - - {$_('sign in to your existing account')} - -

    -
    - -
    -
    - -
    -
    - - - {#if errors.firstName} -

    {$_(errors.firstName)}

    - {/if} -
    - -
    - - - {#if errors.lastName} -

    {$_(errors.lastName)}

    - {/if} -
    -
    - - -
    - - - {#if errors.email} -

    {$_(errors.email)}

    - {/if} -
    - - -
    - - - {#if errors.password} -

    {$_(errors.password)}

    - {/if} - {@render passwordRequirementList({ value: password, includeMatch: false })} -
    - - -
    - - - {#if errors.confirmPassword} -

    {$_(errors.confirmPassword)}

    - {/if} - {@render passwordRequirementList({ value: confirmPassword, includeMatch: true })} -
    - - -
    - - - {#if errors.company} -

    {$_(errors.company)}

    - {/if} -
    - - -
    -

    - {$_('Required Agreements')} * -

    -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - - {#if errors.terms} -

    {$_(errors.terms)}

    - {/if} - - {#if !agreedToTerms || !agreedToPrivacy || !agreedToCookies} -

    - {$_('All three agreements must be accepted to register')} -

    - {/if} -
    -
    - -
    - - - {#if !isFormValid && !isSubmitting} -

    - Please complete all required fields and accept all agreements to register -

    - {/if} -
    -
    -
    -
    - - diff --git a/src/routes/auth/update-password/+page.server.ts b/src/routes/auth/update-password/+page.server.ts deleted file mode 100644 index 1607bb06..00000000 --- a/src/routes/auth/update-password/+page.server.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { AuthService } from '$lib/services/AuthService'; -import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; - -// Handle password reset token verification and redirect logged-in users -export const load: PageServerLoad = async ({ url, locals }) => { - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); - - // Check if user is already logged in - const user = await authService.getSession(); - if (user) { - // If logged in, redirect to account settings - throw redirect(303, '/account/update-password'); - } - - // Check for password reset token in URL - const token_hash = url.searchParams.get('token_hash'); - const type = url.searchParams.get('type'); - - if (!token_hash || type !== 'recovery') { - // No valid token, redirect to forgot password page - throw redirect(303, '/auth/forgot-password'); - } - - // Verify the reset token - const { error } = await locals.supabase.auth.verifyOtp({ - token_hash, - type: 'recovery' - }); - - if (error) { - console.error('Token verification error:', error); - // Invalid or expired token, redirect to forgot password - throw redirect(303, '/auth/forgot-password'); - } - - return { - tokenVerified: true - }; -}; - -export const actions: Actions = { - default: async ({ request, locals }) => { - try { - // Parse form data - const formData = await request.formData(); - const newPassword = formData.get('newPassword')?.toString() || ''; - const confirmPassword = formData.get('confirmPassword')?.toString() || ''; - - // Validate password - if (!newPassword) { - return fail(400, { - error: 'New password is required', - newPassword: '', - confirmPassword: '' - }); - } - - if (newPassword.length < 8) { - return fail(400, { - error: 'Password must be at least 8 characters long', - newPassword: '', - confirmPassword: '' - }); - } - - // Validate password confirmation - if (newPassword !== confirmPassword) { - return fail(400, { error: 'Passwords do not match', newPassword: '', confirmPassword: '' }); - } - - // Get services - const errorHandler = new ErrorHandlingService(); - const authService = new AuthService(locals.supabase, errorHandler); - - // Update password - const result = await authService.updatePassword(newPassword); - - if (!result.success) { - console.error('Password update error:', result.error); - return fail(400, { - error: result.error || 'Failed to update password', - newPassword: '', - confirmPassword: '' - }); - } - - // Password updated successfully - return { - success: true - }; - } catch (error) { - console.error('Password update request error:', error); - return fail(500, { - error: 'An error occurred. Please try again later.', - newPassword: '', - confirmPassword: '' - }); - } - } -}; diff --git a/src/routes/auth/update-password/+page.svelte b/src/routes/auth/update-password/+page.svelte deleted file mode 100644 index dc4c5804..00000000 --- a/src/routes/auth/update-password/+page.svelte +++ /dev/null @@ -1,216 +0,0 @@ - - - - {$_('Update Password')} | CropWatch - - - -
    - -
    -
    -

    {$_('Update Password')}

    - - {#if form?.success} -
    -

    - {$_('Your password has been successfully updated!')} -

    -

    - {$_('You can now log in with your new password.')} -

    - - - {$_('Go to login')} → - -
    - {:else} -

    - {$_('Please enter your new password below.')} -

    - -
    -
    - - -

    {$_('Minimum 8 characters required')}

    -
    - -
    - - -
    - - {#if form?.error} -
    - {$_(form.error)} -
    - {/if} - -
    - -
    - - -
    - {/if} -
    -
    - - diff --git a/src/routes/demo/+page.svelte b/src/routes/demo/+page.svelte new file mode 100644 index 00000000..646de602 --- /dev/null +++ b/src/routes/demo/+page.svelte @@ -0,0 +1,6 @@ + + +{m.demo_paraglide_link()} diff --git a/src/routes/demo/paraglide/+page.svelte b/src/routes/demo/paraglide/+page.svelte new file mode 100644 index 00000000..58f174bd --- /dev/null +++ b/src/routes/demo/paraglide/+page.svelte @@ -0,0 +1,22 @@ + + +

    {m.hello_world({ name: 'SvelteKit User' })}

    + +
    + + +
    + +

    + {m.demo_paraglide_vscode_prefix()} + + {m.demo_paraglide_sherlock_link()} + + {m.demo_paraglide_vscode_suffix()} +

    diff --git a/src/routes/layout.css b/src/routes/layout.css new file mode 100644 index 00000000..3a7c85cf --- /dev/null +++ b/src/routes/layout.css @@ -0,0 +1,71 @@ +/* ── Import CWUI styles in a layer so Tailwind utilities can override them ── */ +@import '@cropwatchdevelopment/cwui/styles' layer(cwui); +@import 'tailwindcss'; + +/* ── Ensure Tailwind scans all source files and CWUI components ── */ +@source '../../src'; +@source '../../node_modules/@cropwatchdevelopment/cwui'; + +:root { + --app-shell-gutter: var(--cw-space-3); +} + +html, +body { + height: 100%; + overflow: hidden; + background: var(--cw-bg-base); +} + +body { + margin: 0; + color: var(--cw-text-primary); +} + +.app-shell { + display: flex; + width: 100%; + height: 100dvh; + overflow: hidden; + background: var(--cw-bg-base); +} + +.app-shell__content { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.app-shell__main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; + overscroll-behavior: contain; +} + +.app-shell__bottom-chrome { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + background: var(--cw-bg-base); +} + +/* This hides the reCAPTCHA badge, do not touch it! */ +.grecaptcha-badge { visibility: hidden !important; } + +.app-shell__main--standalone { + height: 100%; +} + +@media (min-width: 640px) { + :root { + --app-shell-gutter: var(--cw-space-4); + } +} diff --git a/src/routes/legal/+page.svelte b/src/routes/legal/+page.svelte deleted file mode 100644 index 424ffb04..00000000 --- a/src/routes/legal/+page.svelte +++ /dev/null @@ -1,31 +0,0 @@ -
    -
    -

    - Legal Information -

    - -
    -
    - - diff --git a/src/routes/legal/EULA/+page.svelte b/src/routes/legal/EULA/+page.svelte deleted file mode 100644 index d00d07f7..00000000 --- a/src/routes/legal/EULA/+page.svelte +++ /dev/null @@ -1,759 +0,0 @@ -
    -
    -
    -

    - サービス利用規約 -

    -
    -
    -
    -
    -

    - 第1章 総則 -

    -
    -
    -

    - 第1.1条(本規約について) -

    -

    - このサービス利用規約(以下「本規約」という。)は、合同会社クロップウォッチ(以下「当社」という。)が提供するデータの提供等に関するクラウドサービス(以下「本サービス」という。)を利用者が利用するにあたり遵守すべき事項等を定める。 -

    -
    -
    -

    - 第1.2条(定義) -

    -

    - 本規約においては、次の用語はそれぞれ次の意味で使用する。 -

    -
      -
    • - (1) - 「管理責任者」とは、顧客を代理して顧客の利用者アカウントの管理並びに本サービス利用契約の変更及び解約等を行う権限を付与された顧客の役員又は従業員(顧客が個人の場合は当該個人)をいう。 -
    • -
    • - (2) 「顧客」とは、本規約に同意して当社と本サービス利用契約を締結した事業者をいう。 -
    • -
    • - (3) - 「知的財産権」とは、著作権(著作権法第27条及び第28条に定める権利を含む。)、特許権、実用新案権、意匠権、商標権その他の知的財産権(これらを受ける権利及び出願中の権利を含む。)をいう。 -
    • -
    • - (4) - 「匿名データ」とは、利用者情報及び本データに対して一定の処理(統計的な処理又は解析を含む。)を加え、利用者を含む個人が特定又は識別されないよう加工した情報をいう。 -
    • -
    • - (5) - 「パスワード」とは、ログインIDと組み合わせて、本サービス上で利用者をその他の利用者と区別するために用いられる符号であり、利用者が所定の条件に従って自ら設定する半角英数字及び記号の組み合わせをいう。 -
    • -
    • - (6) - 「本サービス利用契約」とは、顧客と当社との間の本サービスの利用に関する契約をいう。 -
    • -
    • - (7) - 「本ソフトウェア」とは、本サービスのために当社が提供するソフトウェア(本ハードウェアに組み込まれるものを含む。)をいう。 -
    • -
    • - (8) - 「本データ」とは、本サービスを通じて収集されるデータ及びこれらに加工又は分析等を加えたデータをいう。 -
    • -
    • - (9) - 「本ハードウェア」とは、本データの収集、送信、機器の自動化等のために当社が提供し、又は利用者が準備する情報端末、センサ、通信機器その他のハードウェアをいう。 -
    • -
    • - (10) - 「利用者」とは、本規約に同意して本サービスを利用する顧客の役職員その他の顧客が指定した個人をいう。 -
    • -
    • - (11) - 「利用者アカウント」とは、顧客及び利用者が作成した本サービスを利用するためのアカウントをいう。 -
    • -
    • - (12) - 「利用者情報」とは、顧客又は利用者が本サービスに登録する情報その他第3.1条に具体的に定める情報を含む、本サービスの利用を通じて顧客又は利用者から直接又は間接に当社に提供される全ての情報、並びにこれらの情報に加工又は分析等を加えた情報(ただし、本データを除く。)をいう。 -
    • -
    • - (13) - 「ログインID」とは、本サービスの利用に際して各利用者をその他の利用者と区別するために用いられるメールアドレスその他の符号であり、利用者が自ら登録する半角英数字及び記号の組み合わせをいう。 -
    • -
    -
    -
    -

    - 第1.3条(本規約の適用・変更) -

    -
      -
    1. - 当社は、顧客及び利用者に対して、本規約に記載する条件にて本サービス及び本ソフトウェアの利用を許諾する。 -
    2. -
    3. - 本規約は、顧客と当社との間の本サービス利用契約及び利用者による本サービスの利用に適用される。但し、当社は、本規約とは別に、当社が提供する個別の本サービス(以下「個別サービス」という。)の内容に応じて、当該個別サービスの利用条件(名称の如何を問わず、以下「個別条件」といいます。)を定め、当社ウェブサイト上で掲載等する場合があり、この場合、個別サービスの利用者は、個別条件に同意のうえ、個別サービスを利用するものとする。 -
    4. -
    5. - 前項の場合を含め、顧客及び当社が本サービス利用契約において本規約の条件と異なる条件を合意した場合は、当該本サービス利用契約の条件が本規約の条件に優先するものとする。 -
    6. -
    7. - 当社は、いつでも本規約を変更することができるものとする。但し、本規約の変更を行うときは、その効力発生時期を定め、かつ、本規約を変更する旨及び変更後の本規約の内容並びにその効力発生時期を当社ウェブサイトを通じて公開する方法その他の適切な方法により顧客及び利用者に周知するものとする。 -
    8. -
    9. - 顧客及び利用者は、前項に基づき定める変更後の本規約の効力発生時期以降に本サービスの利用を継続した場合、変更後の本規約の内容に合意したものとみなされるものとし、かかる合意に基づいて本サービス利用契約の内容も変更されるものとする。 -
    10. -
    -
    -
    -

    - 第1.4条(本サービス利用契約) -

    -

    - 顧客が当社所定の方法により本サービスの利用を申し込み、当社が当該申込みを承諾することにより本サービス利用契約を締結する。但し、当該顧客が過去に当社との間の契約に基づく義務に違反した等合理的な理由がある場合、当社は当該顧客との間の本サービス利用契約の締結を拒絶することができる。 -

    -
    -
    -

    - 第1.5条(委託) -

    -

    - 当社は、顧客及び利用者に対する本サービスの提供又は本サービス若しくは本ソフトウェアの開発若しくは保守等に関して必要となる業務の全部又は一部を、当社の判断において国内及び国外の第三者(以下「委託先」という。)に委託することができる。 -

    -
    -
    -

    - 第1.6条(設備等の準備及び維持) -

    -
      -
    1. - 顧客は、当社が認めた場合を除き、自己の費用と責任において、当社が定める条件にて本ハードウェア及びLoRaWAN基地局の設置及び設定を行い、本サービス利用契約の期間中、本サービス(本ハードウェア及びLoRaWAN基地局を含む。)が利用可能な環境を維持する。 -
    2. -
    3. - 顧客は、本サービスを利用するために必要な場合は、自己の責任と費用において、電気通信事業者等の電気通信サービスを利用してLoRaWAN基地局をインターネットに接続する。 -
    4. -
    5. - 顧客は、LoRaWAN接続、本ハードウェア(第6.1条第9項に基づく保証の期間内のものを除く。)、前項に定めるインターネット接続その他本サービスを利用するための環境に不具合がある場合又はそれらに関するセキュリティ上の問題が生じた場合、本サービスの利用ができなくなる可能性があることを了承し、自己の費用と責任において、十分な対策(セキュリティ対策を含む。)を講じるものとする。当社は、当該不具合又はセキュリティ上の問題により顧客又は利用者が本サービスを利用できないことに対して責任を負わない。 -
    6. -
    -
    -
    -
    - -
    -

    - 第2章 本サービスの利用等 -

    -
    -
    -

    - 第2.1条(本サービスの利用) -

    -
      -
    1. - 本サービス利用契約に基づき、当社は顧客及び利用者に対して、本ソフトウェアの利用を行うための制限的かつ譲渡不可能なライセンスを許諾する。 -
    2. -
    3. - 顧客及び利用者には、本規約及び本サービス利用契約を遵守する限度において、本規約及び本サービス利用契約に定める利用目的の範囲内に限り、本サービスを利用する権限が付与されるものとし、本サービスに含まれる全ての権限、著作権その他知的財産権は当社又は当社にライセンスを許諾している者に帰属する。 -
    4. -
    5. - 顧客及び利用者は、本サービスの利用権を第三者に譲渡することはできず、当社から書面により明示の権限を付与されない限り、本サービスに含まれる如何なる権利も第三者にサブライセンスすることはできない。 -
    6. -
    -
    -
    -

    - 第2.2条(アカウントの管理等) -

    -
      -
    1. - 顧客は、利用者アカウントのログインID及びパスワードを、自己の責任のもと厳重に管理し、第三者への貸与等本規約に定める利用目的に反した一切の利用を行わない。 -
    2. -
    3. - 利用者アカウントのログインID及びパスワードを用いた本サービスの利用行為は、全て当該利用者による行為とみなすものとし、利用者の同意の有無を問わず、第三者が利用者アカウントのログインID及びパスワードを用いて本サービスを利用した場合であっても、顧客はかかる利用について一切の責任を負う。また、当該第三者の行為により当社が損害を被った場合、顧客はその損害を補償する。 -
    4. -
    5. - 顧客は、利用者アカウントのログインID及びパスワードを用いた本サービスの利用において、本規約又は本サービス利用契約の違反があったことを知った場合、又はそのおそれがあると判断した場合は、直ちに当社に通知するものとし、同違反について一切の責任を負う。 -
    6. -
    -
    -
    -

    - 第2.3条(利用料金) -

    -
      -
    1. - 本サービスの利用は有料であり、顧客は本サービスの利用の対価として当社所定の利用料金(以下「本サービス利用料金」という。)を支払わなければならない。本サービス利用料金は、当社が別途顧客に対して提示する条件に従うものとする。 -
    2. -
    3. - 本サービス利用料金の支払方法及び支払時期等は、当社が別途顧客に対して提示する条件に従うものとする。 -
    4. -
    -
    -
    -

    - 第2.4条(契約期間) -

    -

    - 本サービス利用契約の契約期間及び更新条件は、当社が別途顧客に対して提示する条件に従うものとする。 -

    -
    -
    -
    - -
    -

    - 第3章 利用者情報の取得・利用等 -

    -
    -
    -

    - 第3.1条(利用者情報の取得・利用等) -

    -
      -
    1. - 顧客及び利用者は、本サービスの利用に際して、本ソフトウェアを通じて又はその他の方法により、当社が下記各号に定める情報を含む利用者情報を取得することに同意する。なお、利用者情報には、個人情報(個人情報の保護に関する法律に定める個人情報をいう。以下同じ。)が含まれる場合がある。 -
        -
      • - (1) - 顧客及び利用者が本サービス利用契約締結時に登録した住所、氏名、メールアドレス等の情報 -
      • -
      • (2) 顧客の管理責任者の氏名、メールアドレス等の情報
      • -
      • - (3) - 雇用、委任等の形態を問わず、外形上顧客及び利用者のために利用者のログインID及びパスワードを用いて本サービスを利用する個人が利用時に登録した氏名、メールアドレス等の情報 -
      • -
      • (4) 本ソフトウェアの利用履歴
      • -
      • (5) その他、本サービスの性能を実現するために必要な情報
      • -
      -
    2. -
    3. - 顧客及び利用者は、当社及び委託先が、下記各号に定める目的のために利用者情報を閲覧、利用及び管理することをあらかじめ同意する。 -
        -
      • - (1) 顧客及び利用者に対し本サービス及びこれに付随するサービスを提供する目的 -
      • -
      • (2) メンテナンス又はセキュリティ上の対応等を実施する目的
      • -
      • (3) 顧客及び利用者へのアフターサービスを実施する目的
      • -
      • (4) 本ソフトウェアを開発、提供又は保守等する目的
      • -
      • - (5) - 顧客及び利用者以外の者に対するアフターサービスその他のサポート体制を向上させる目的 -
      • -
      • (6) 当社の製品・サービスの開発・改善等のために利用する目的
      • -
      • (7) 統計数値その他匿名データの作成及びその分析のために利用する目的
      • -
      • - (8) - 当社と顧客及び利用者との間のコミュニケーション(アンケート、問い合わせ対応等を含む) -
      • -
      • (9) 本サービスの不正利用(なりすまし等)の防止
      • -
      • (10) 紛争、訴訟等の対応
      • -
      • - (11) - 当社のその他の事業目的(開発、設計、エンジニアリング、生産、販売又は本サービスの提供・改善を含むがこれらに限られない。)を遂行する目的 -
      • -
      -
    4. -
    5. - 前項に定める場合を除き、当社は、匿名データ以外の利用者情報を第三者に提供するときは、顧客又は利用者の承諾を得るものとする。但し、以下の場合はこの限りでない。 -
        -
      • (1) 法令に基づいて、開示が必要であると当社が合理的に判断した場合
      • -
      • - (2) - 人の生命、身体又は財産の保護のために必要がある場合であって、本人の同意を得ることが困難であると判断した場合 -
      • -
      • - (3) - 公衆衛生の向上又は児童の健全な育成の推進のために特に必要がある場合であって、本人の同意を得ることが困難であると判断した場合 -
      • -
      • - (4) - 国の機関若しくは地方公共団体又はその委託を受けた者が法令の定める事務を遂行することに対して協力する必要がある場合であって、本人の同意を得ることにより当該事務の遂行に支障を及ぼすおそれがあると判断した場合 -
      • -
      • - (5) - 合併その他の事由により本サービスの権利者、サービスの主体が変更され、サービスの継続のため個人情報を移管する必要があると判断した場合 -
      • -
      -
    6. -
    7. - 顧客及び利用者は、個人情報保護法その他の法令を遵守の上、自身の責任において、利用者情報を第三者に提供することができる。かかる第三者に対する利用者情報の提供に関して、当社は一切責任を負わず、当該第三者への利用者情報の提供により又はこれに関連して当社が損害を被った場合、顧客及び利用者はその損害を補償する。 -
    8. -
    9. - 顧客及び利用者は、本サービスの利用による利用者情報の提供に際して、本データの主体又は本ハードウェアを利用若しくは所持する主体が顧客及び利用者と異なる場合には、情報の取得について当該主体に対して十分な説明を実施するものとする。 -
    10. -
    11. - 顧客及び利用者は、利用者情報のうち、個人情報に該当するため送信を希望しない情報がある場合には、当該情報の削除を行う又は当社に削除を依頼することができる。この場合、当該情報が取得されないことにより、本サービスの機能の一部が利用できなくなることがあることを了承する。 -
    12. -
    -
    -
    -

    - 第3.2条(個人情報の保護) -

    -
      -
    1. - 当社は、本サービスに関して取得した又は提供を受けた個人情報を、前条に定める利用目的の範囲内でのみ使用し、また、前条の規定に従い第三者に提供する。 -
    2. -
    3. - 当社は、前項に定める個人情報を、当社ウェブサイト上で公開するプライバシーポリシーに従い、適切に取り扱うものとする。 -
    4. -
    -
    -
    -

    - 第3.3条(利害関係者の同意等) -

    -
      -
    1. - 顧客及び利用者は、利用者情報に第三者の情報が含まれる場合は、当該情報を当社に提供する前に、第3.1条の内容につき当該第三者の同意を得るものとする。 -
    2. -
    3. - 顧客及び利用者は、本サービスの利用開始時及び利用期間中において、前項の同意を取得していること、利用者情報について適法な権利を有していること及び第3.1条第2項各号に定める利用者情報の利用が第三者の権利を侵害しないことを保証する。顧客及び利用者は、当該保証の違反により第三者との間で生じた紛争等について、自らの責任において対処するものとし、当社はこれらに関して一切責任を負わない。 -
    4. -
    -
    -
    -
    - -
    -

    - 第4章 本データの取得・管理等 -

    -
    -
    -

    - 第4.1条(本データの取得・管理等) -

    -
      -
    1. - 顧客及び利用者は、本データが、当社が管理するクラウドサーバに送信、保存、管理されることに同意する。なお、当該サーバでの本データの保存期間は2年間とし、当社は、保存期間を経過した本データを当該サーバから削除できるものとする。 -
    2. -
    3. - 顧客及び利用者は、当社が、下記各号に定める目的のために本データを閲覧、利用及び管理することをあらかじめ同意する。 -
        -
      • - (1) 顧客及び利用者に対し本サービス及びこれに付随するサービスを提供する目的 -
      • -
      • (2) メンテナンス又はセキュリティ上の対応等を実施する目的
      • -
      • (3) 顧客及び利用者へのアフターサービスを実施する目的
      • -
      • (4) 本ソフトウェアを開発又は保守等する目的
      • -
      • (5) 統計数値の作成及びその分析のために利用する目的
      • -
      • (6) 本サービスの不正利用(なりすまし等)の防止
      • -
      • (7) 紛争、訴訟等の対応
      • -
      -
    4. -
    5. - 顧客及び利用者は、当社が匿名データを第三者に提供する場合があることをあらかじめ同意する。 -
    6. -
    7. - 前二項の場合を除き、当社は、匿名データ以外の本データを第三者に提供するときは、顧客及び利用者の承諾を得るものとする。ただし、法令の定めに基づく場合又は権限ある官公署からの要求を受けた場合はこの限りではない。 -
    8. -
    9. - 顧客及び利用者は、個人情報保護法その他の法令を遵守の上、自身の責任において、本データを第三者に提供することができる。かかる第三者に対する本データの提供に関して、当社は一切責任を負わず、当該第三者への本データの提供により又はこれに関連して当社が損害を被った場合、顧客及び利用者はその損害を補償する。 -
    10. -
    11. - 顧客及び利用者は、第2項に基づく当社による本データの利用等及び顧客又は利用者から第三者への本データの提供に際して、本データの主体又は本ハードウェアを利用若しくは所持する主体が顧客及び利用者と異なる場合には、本データの取得について当該主体に対して十分な説明を実施するものとする。 -
    12. -
    -
    -
    -

    - 第4.2条(利害関係者の同意等) -

    -
      -
    1. - 顧客及び利用者は、本データに第三者の情報が含まれる場合は、当該情報を提供する前に、第4.1条の内容につき当該第三者の同意を得るものとする。 -
    2. -
    3. - 顧客及び利用者は、本サービスの利用開始時及びその利用期間中において、前項の同意を取得していること、本データについて適法な権利を有していること及び第4.1条第2項各号に定める当社による本データの利用が第三者の権利を侵害しないことを保証する。顧客及び利用者は、当該保証の違反により第三者との間で生じた紛争等について、自らの責任において対処するものとし、当社はこれらに関して一切責任を負わない。 -
    4. -
    -
    -
    -
    - -
    -

    - 第5章 禁止事項 -

    -
    -

    - 第5.1条(禁止事項) -

    -

    - 顧客及び利用者は、本サービスの利用に際して、下記各号の行為を行ってはならない。なお、当社は、下記各号に該当する行為に関連する情報及びデータについては、任意に削除することができる。 -

    -
      -
    • - (1) - 当社若しくは第三者の知的財産権その他の権利を侵害する行為又は侵害するおそれのある行為 -
    • -
    • (2) 本サービスの内容や本サービスにおいて利用しうる情報を改ざん又は消去する行為
    • -
    • - (3) - 匿名データに係る個人又は顧客を識別するために当該匿名データを他の情報と照合し、また、当該本人を特定する行為又はこれらの照合若しくは特定をしようとする行為 -
    • -
    • - (4) - 本ハードウェア又は本ソフトウェアの全部又は一部を変更、リバースエンジニアリング、逆コンパイル、分解し、それらの派生物を生成し、その他の方法でソースコードを解読する等の行為 -
    • -
    • (5) 本規約に定める以外の目的又は方法で、本サービスを利用する行為
    • -
    • - (6) - 本規約若しくは本サービス利用契約に反する態様又は当社の判断により不適当とみなした態様で本サービスを利用する行為 -
    • -
    • (7) 本規約又は本サービス利用契約に違反して、第三者に本サービスを利用させる行為
    • -
    • (8) 第三者になりすまして本サービスを利用する行為
    • -
    • (9) 法令又は公序良俗に違反する行為
    • -
    • (10) 他者を差別若しくは誹謗中傷し、又はその名誉若しくは信用を毀損する行為
    • -
    • (11) 詐欺等の犯罪に結びつく又は結びつくおそれがある行為
    • -
    • (12) わいせつ、児童ポルノ又は児童虐待にあたる画像、文書等を登録する行為
    • -
    • (13) 無限連鎖講を開設し、又はこれを勧誘する行為
    • -
    • - (14) コンピュータ・ウイルス等の有害なデータ、コンピュータ・プログラム等を登録する行為 -
    • -
    • - (15) - 広告、宣伝若しくは勧誘のために本サービスを利用する行為、又は第三者が嫌悪感を抱く若しくはそのおそれのある表現を含む情報を本サービスに登録する行為 -
    • -
    • (16) 本サービスの提供・動作に支障を与える行為、又は与えるおそれのある行為
    • -
    • (17) 面識のない者との出会いを目的とした情報を登録する行為
    • -
    • - (18) - ある行為が前各号のいずれかに該当することを知りつつ、その行為を助長する態様・目的でリンクを貼る行為 -
    • -
    • (19) その他、当社が不適切と判断する行為
    • -
    -
    -
    - -
    -

    - 第6章 保証・責任 -

    -
    -
    -

    - 第6.1条 (保証・責任の制限) -

    -
      -
    1. - 当社は、本サービスを現状有姿で提供するものとし、本サービス又は本ソフトウェアに瑕疵・バグ等が存在する場合又はシステムの過負荷、不具合等により本サービスの利用等が停止する場合においても、当社は本規約に規定する限度においてのみ責任を負うものとする。 -
    2. -
    3. - 当社は、当社が必要と判断した場合には、顧客及び利用者に通知することなくいつでも本サービスの内容を変更し、又は本サービスの提供を停止若しくは中止することができるものとする。本サービスの提供を停止又は中止した場合、当社は顧客及び利用者に対して、月額等で継続的に支払われる利用料の精算を除き、一切責任を負わないものとする。 -
    4. -
    5. - 当社は、本サービス及び本ソフトウェアの完全性、有用性、信頼性、真実性、正確性、妥当性、動作保証、特定の目的への適合性、使用機器への適合性、適法性、第三者の権利を侵害していないことその他本サービス及び本ソフトウェアの機能及び性質について一切の保証を行わないものとし、顧客及び利用者は自らの判断及び責任に基づき本サービスを利用するものとする。 -
    6. -
    7. - 当社は、本サービスのために使用される本ハードウェア又はサーバの故障・トラブル、停電、通信回線の異常及びシステム障害等の不可抗力により発生する障害について、一切責任を負わないものとする。この場合、利用者情報、本データその他顧客又は利用者に関するデータの取得の失敗、消失等が発生することがあり、また、かかる事態の発生により利用者情報、本データその他顧客又は利用者に関するデータが消失、紛失、遅延等した場合、本ハードウェアの利用制限や初期化が行われる可能性があることを顧客及び利用者は理解し、顧客及び利用者はかかる消失等につき当社に対して責任を追及しない。 -
    8. -
    9. - 当社は、利用者情報及び本データの保護に関し、業界標準に照らし合理的なセキュリティ対策を講じるものとする。但し、第三者による不正アクセス、盗難、破壊、改ざん等により生じた損害については、顧客又は利用者と不正アクセス等を実施した第三者との間でこれを解決するものとする。 -
    10. -
    11. - 当社は、利用者情報及び本データについてバックアップを取る義務を負わない。顧客及び利用者は、利用者情報及び本データのバックアップについて、自己の責任で定期的に実施する。 -
    12. -
    13. - 当社は、顧客及び利用者が本ハードウェアとUSBメモリ等の外部記憶装置を接続し、データの書き出し若しくは書き込みを行う場合又は本ハードウェアがデータ通信を行う場合、当該外部記憶装置又は転送されるデータがコンピュータ・ウイルスに感染していないことを保証するものではなく、かかる感染に起因する損害について一切の責任を負わない。 -
    14. -
    15. - 顧客又は利用者の操作により、本サービスが、第三者が提供する他のアプリケーションその他ソフトウェアを呼び出す場合又は第三者が提供する他のアプリケーションその他ソフトウェアの機能を利用する場合、当該アプリケーションその他ソフトウェアの仕様、動作、機能等並びに本サービスとの接続及び連携等について、当社は一切の責任を負わない。 -
    16. -
    17. - 本ハードウェアのうち、当社製品の保証については、当社が別途顧客に交付する保証書等に定める内容によるものとする。 -
    18. -
    19. - 本ハードウェアのうち、当社製品でない製品の保証については、当該製品のメーカーの保証によるものとする。また、当社は第三者製の本ハードウェアの仕様、性能、精度、再現性及び測定値の正確性等について一切保証せず、第三者製の本ハードウェアの不具合、誤動作又は不正確なデータ等に起因して発生した一切の損害について、当社は責任を負わない。 -
    20. -
    21. - 前二項の規定にかかわらず、本ハードウェアのうちセンサ(当社製品であるか否かを問わない。)の測定結果は、その使用環境、条件、経年劣化、キャリブレーション状態等の影響を受ける可能性があり、当社は、センサ及びその測定結果の完全性、有用性、信頼性、正確性、妥当性、及びこれに基づき顧客が行う意思決定または対応に起因する結果について、一切の責任を負わない。 -
    22. -
    -
    -
    -

    - 第6.2条(違反行為への対応) -

    -
      -
    1. - 当社は、顧客又は利用者の行為が本規約又は本サービス利用契約に違反すると判断した場合、当該顧客又は利用者への事前の通知なしに、利用者情報及び本データの全部又は一部の削除を行い、本サービスの利用の停止又は制限等、当社が適当と判断する措置を講ずることができる。 -
    2. -
    3. - 当社は、前項の規定に基づき当社が講じた措置に起因する損害が発生した場合であっても、一切責任を負わないものとする。 -
    4. -
    5. - 前二項の規定は、当社が当該処置を講じることにより当社又は第三者に損害が発生した場合における、顧客及び利用者の責任を免責するものではない。顧客及び利用者は、本規約又は本サービス利用契約に違反したことにより第三者に損害を与えた場合又は第三者と紛争を生じさせた場合、自己の責任と費用でこれを解決し、当社にいかなる責任も負担させないものとする。また、顧客及び利用者は、当社が他の顧客又は利用者や第三者から責任を追及された場合は、その責任と費用において当該紛争を解決するものとし、当社にいかなる責任も負担させないものとする。 -
    6. -
    -
    -
    -

    - 第6.3条(利用者情報等による損害) -

    -
      -
    1. - 当社は、利用者情報又は本データに起因して本サービス又は当社のサーバに支障が生じた場合若しくはそのおそれがある場合、事前に顧客及び利用者の承諾を得ることなく、自ら又は委託先をして、利用者情報及び本データの一部又は全部の削除等当社が適当と判断する措置を講ずることができる。 -
    2. -
    3. - 当社は、前項の規定に基づき当社が講じた措置に起因する損害が発生した場合であっても、一切責任を負わないものとする。 -
    4. -
    5. - 前二項の規定は、当社が当該処置を講じることにより当社又は第三者に損害が発生した場合における、顧客及び利用者の責任を免責するものではない。 - 顧客及び利用者は、当該措置に起因して顧客及び利用者に発生した損害について、当社にいかなる責任も負担させないものとする。 -
    6. -
    -
    -
    -

    - 第6.4条(当社による損害賠償) -

    -
      -
    1. - 当社は、本サービスに関して顧客及び利用者に生じた損害について、当社に故意または重過失が認められる場合に限り、その損害を賠償する責任を負うものとし、以下各号の事由により生じた損害については一切責任を負わない。 -
        -
      • - (1) - 地震、津波、落雷、洪水等の天変地異、新型コロナウィルス、インフルエンザ、鳥インフルエンザ等の感染症、戦争(日本国外のものを含む)、革命、暴動、テロ行為等の騒乱、本サービスの提供に影響する法改正(特にAI及び個人情報を巡る規制)、輸送機関・通信回線等の事故、その他の不可抗力 -
      • -
      • - (2) - 当社以外の者が本ハードウェア、LoRaWAN基地局その他本サービスに供される機器又は設備(以下「本件設備等」という。)に対し加えた損傷等 -
      • -
      • (3) 本件設備等以外の設備又は機器に起因する事故
      • -
      • - (4) - 本サービス利用契約締結時点での一般的な技術水準に従った管理をしていたにもかかわらず発生した不具合(第三者によるサーバ攻撃、インターネットのダウン等通信環境の不調、サーバ提供事業者の債務不履行(倒産や故障を含む)等に起因するものを含む) -
      • -
      • - (5) - 本ハードウェアを通じて収集した情報に誤り・偏り等が含まれることでなされたAIの不適切な学習 -
      • -
      • (6) 顧客及び利用者以外の第三者が被った損害
      • -
      • (7) その他、発生原因を特定できない事故等
      • -
      -
    2. -
    3. - 当社は、本規約において顧客又は利用者の責任としている事項および当社が保証しない又は責任を負わないものとしている事項については、一切の責任を負わない。 -
    4. -
    5. - 第一項の規定にかかわらず、当社の賠償責任は、顧客及び利用者が直接被った通常の損害に限定されるものとし、付随的損害、間接損害、特別損害、将来の損害および逸失利益にかかる損害については、賠償する責任を負わない。また、賠償すべき損害の金額は、直近12か月間に顧客が当社に支払った本サービス利用料金の合計に相当する金額を上限とする。 -
    6. -
    -
    -
    -
    - -
    -

    - 第7章 その他 -

    -
    -
    -

    - 第7.1条(秘密保持義務) -

    -
      -
    1. - 顧客、利用者及び当社は、本サービスに関して知り得た相手方の技術上又は営業上その他業務上の情報(但し、利用者情報を除く。以下「秘密情報」という。)を、本サービスの利用の目的の範囲内でのみ使用し、事前に相手方の書面による承諾を得ない限り第三者(当社においては委託先を除く。)に開示又は漏洩しない。但し、下記各号のいずれかに該当することを立証できる情報は秘密情報に含まれない。 -
        -
      • (1) 秘密保持義務を負うことなく既に保有している情報
      • -
      • (2) 秘密保持義務を負うことなく第三者から正当に入手した情報
      • -
      • (3) 相手方から提供を受けた情報によらず、独自に開発した情報
      • -
      • - (4) - 本規約及び本サービス利用契約に違反することなく、かつ、受領の前後を問わず公知となった情報 -
      • -
      -
    2. -
    3. - 前項の定めにかかわらず、顧客、利用者及び当社は、秘密情報のうち法令の定めに基づき又は権限ある官公署からの要求により開示すべき情報を、当該法令の定めに基づく開示先又は当該官公署に対し開示することができる。この場合、顧客、利用者及び当社は、関連法令に反しない限り、当該開示前に開示する旨を相手方に通知するものとし、開示前に通知を行うことができない場合は開示後速やかにこれを行う。 -
    4. -
    5. - 第1項の定めにかかわらず、当社が必要と認めた場合には、当社はその委託先に対して、本条に定める義務を遵守させることを条件に、必要な範囲で、顧客又は利用者から事前の書面による承諾を受けることなく顧客及び利用者の秘密情報を開示することができる。 -
    6. -
    -
    -
    -

    - 第7.2条(反社会的勢力の排除) -

    -
      -
    1. - 本規約において、「反社会的勢力」とは、下記各号の一に該当する者をいう。 -
        -
      • - (1) - 暴力団員による不当な行為の防止等に関する法律の第2条第2号に定義される暴力団及びその関係団体 -
      • -
      • (2) 前号記載の暴力団及びその関係団体の構成員
      • -
      • - (3) - 「総会屋」「社会運動標榜ゴロ」「政治活動標榜ゴロ」「特殊知能暴力集団」などの団体又は個人 -
      • -
      • - (4) - 前各号の一の他、暴力、威力、脅迫的言辞及び詐欺的手法を用いて不当な要求を行い、経済的利益を追求する団体又は個人 -
      • -
      • - (5) - 前各号の一の団体、構成員又は個人と関係を有することを示唆して不当な要求を行い、経済的利益を追求する団体又は個人 -
      • -
      -
    2. -
    3. - 顧客は、当社に対し、下記各号について表明し、保証する。 -
        -
      • - (1) - 自ら及び利用者アカウントのログインID及びパスワードを用いて本サービスを利用する者が反社会的勢力でないこと -
      • -
      • - (2) - 自ら及び利用者アカウントのログインID及びパスワードを用いて本サービスを利用する者が反社会的勢力でなかったこと -
      • -
      • (3) 反社会的勢力を利用しないこと
      • -
      • - (4) - 取締役、執行役、執行役員その他実質的に経営に関与する者が反社会的勢力でないこと、並びにそれらの者が反社会的勢力と交際がないこと -
      • -
      • - (5) 主要な株主・出資者が反社会的勢力でないこと、及び反社会的勢力と交際がないこと -
      • -
      -
    4. -
    5. - 顧客は、前項に対する自己の違反を発見した場合、直ちに当社にその事実を報告する。 -
    6. -
    7. - 顧客が第2項若しくは前項の規定に違反した場合又はその合理的疑いがある場合、当社は、催告その他何らの手続を要することなく、直ちに本サービス利用契約を解除することができるものとし、これらにより生じた損害の賠償を顧客に請求することができる。また、本項に基づいて本サービス利用契約を解除した場合、当社は、解除により顧客又は利用者に生じた損害について賠償する責任を負わない。 -
    8. -
    -
    -
    -

    - 第7.3条(解約・利用停止) -

    -
      -
    1. - 顧客は、当社所定の方法によって申し込むことにより、本サービス利用契約を解約することができる。この場合の解約に関する条件は、当社が別途顧客に対して提示する条件に従うものとする。 -
    2. -
    3. - 当社は、顧客又は利用者が本規約又は本サービス利用契約に違反し、一定期間を定めて是正を催告したにもかかわらず顧客又は利用者がこれを履行しない場合は、書面又は電子メールによる通知を行うことによって本サービス利用契約の全部又は一部を解約することができる。また、当社は、当該違反が是正されるまでの間、顧客又は利用者による本サービスの利用を一時的に停止することができる。 -
    4. -
    5. - 当社は、顧客が下記各号のいずれかに該当する場合、何ら催告することなく顧客への書面又は電子メールによる通知を行うことによって、顧客及び利用者による本サービスの利用を一時的に停止し、又は本サービス利用契約の全部又は一部を解約することができる。 -
        -
      • (1) 支払停止又は支払不能となった場合
      • -
      • (2) 手形又は小切手が不渡りとなった場合
      • -
      • - (3) - 差押え、仮差押え若しくは競売の申立があったとき又は公租公課の滞納処分を受けた場合 -
      • -
      • - (4) - 破産、会社更生手続開始若しくは民事再生手続開始の申立があったとき又は信用状態に重大な不安が生じた場合 -
      • -
      • (5) 監督官庁から営業許可の取消、停止等の処分を受けた場合
      • -
      • (6) 解散、減資、営業の全部又は重要な一部の譲渡等の決議をした場合
      • -
      • (7) その他前各号に準じる事由が生じた場合
      • -
      -
    6. -
    7. - 当社は、本条の規定に基づき当社が本サービス利用契約を解除し又は顧客若しくは利用者による本サービスの利用を停止したことにより、顧客又は利用者に損害が発生した場合であっても、一切責任を負わないものとする。 -
    8. -
    -
    -
    -

    - 第7.4条(契約終了後の処理) -

    -
      -
    1. - 顧客及び利用者は、本サービス利用契約の終了後は、本サービスを利用することができないものとする。 -
    2. -
    3. - 顧客及び利用者は、本サービス利用契約の終了後、利用者情報、本データその他顧客又は利用者から当社に提供された全てのデータ及び情報を、当社が任意に削除できることに同意する。顧客及び利用者は、適時に必要な全てのデータ及び情報について自らの責任及び負担でバックアップを作成するものとし、当社は顧客及び利用者のデータ並びに情報の喪失について一切責任を負わない。 -
    4. -
    5. - 顧客及び利用者は、本サービスの利用にあたって当社から提供を受けた機器又はソフトウェア(当社から購入した本ハードウェアは除くが、当社からレンタルされた本ハードウェアを含む。)がある場合は、当該機器又はソフトウェア及びこれに関する全ての資料等(当該ソフトウェア及び資料等の複製物を含む。)を、本サービス利用契約終了後直ちに当社に返還する。 -
    6. -
    -
    -
    -

    - 第7.5条(存続条項) -

    -

    - 第3.1条乃至第7.10条は、本サービス利用契約の終了後も引き続き効力を有する。 -

    -
    -
    -

    - 第7.6条(地位等の譲渡禁止) -

    -

    - 顧客及び利用者は、当社の事前の書面による承諾なしに、本規約及び本サービス利用契約上の地位並びに本規約及び本サービス利用契約に基づく権利又は義務の全部若しくは一部を第三者に譲渡してはならない。 -

    -
    -
    -

    - 第7.7条(分離可能性) -

    -

    - 本規約及び本サービス利用契約の各条項は、法律が許す範囲で可能な限り有効となる方法で解釈されるものとし、本規約及び本サービス利用契約のいかなる条項についても法律に違反している又は執行不能と判断される場合には、その条項の残りの部分又は他の条項を無効又は執行不能にすることなく、その条項はその法律違反の限度においてのみ無効又は執行不能となる。 -

    -
    -
    -

    - 第7.8条(専属的合意管轄) -

    -

    - 本規約及び本サービス利用契約に基づく顧客及び当社間の紛争に関しては、宮崎地方裁判所を第一審の専属的合意管轄裁判所とする。 -

    -
    -
    -

    - 第7.9(準拠法) -

    -

    - 本規約及び本サービス利用契約の成立、効力、履行及び解釈に関する準拠法は、日本法とする。 -

    -
    -
    -

    - 第7.10条(誠実協議) -

    -

    - 本規約及び本サービス利用契約に定めのない事項並びに定められた事項の解釈について疑義が生じた場合は両者誠意を持って協議の上解決する。 -

    -
    -
    -
    - -
    -

    - 2024年8月1日 制定・施行
    2025年8月22日 改訂 -

    -
    -
    -
    -
    - - diff --git a/src/routes/legal/cookie-policy/+page.svelte b/src/routes/legal/cookie-policy/+page.svelte deleted file mode 100644 index 9d5d257c..00000000 --- a/src/routes/legal/cookie-policy/+page.svelte +++ /dev/null @@ -1,124 +0,0 @@ -
    -
    -
    -

    - クッキーポリシー -

    -
    -
    -
    -
    -

    - 1. - 合同会社クロップウォッチ(以下「当社」といいます。)が提供するサービス(以下「本サービス」といいます。)においては、クッキー(Cookie)その他情報収集モジュール等(以下「クッキー等」といいます。)を使用しています。当社はクッキー等を通じて、本サービスへの利用者のアクセス情報、閲覧情報等を取得することができます。なお、クッキー等を通じて取得するこれらの情報(以下「クッキー情報」といいます。)には、単独で利用者自身を識別し特定できる情報は含まれていません。 -

    -
    -
    -

    - 2. - 利用者は本サービス上でのクッキー等の使用を許可しない場合には、利用者のブラウザの設定等においてクッキー等を無効にしてください。なお、クッキー等を無効にした場合、本サービスの利便性が損なわれたり、本サービスで提供するサービスのご利用範囲が限定されたりすることがあります。 -

    -
    -
    -

    - 3. 本サービスで利用しているクッキー等の内容については、以下をご参照ください。 -

    - -
    - - - - - - - - - - - - - - - - - - - - - - - -
    クッキー名称送信先取得する情報の概要利用目的
    Google AnalyticsGoogle LLC -
    【例】
    -
      -
    • IPアドレス
    • -
    • 端末固有のユニークID
    • -
    • 利用者の行動情報(サイト上のクリックやページ遷移等)
    • -
    • 利用者が閲覧したページのURL
    • -
    • 位置情報
    • -
    • 滞在時間等
    • -
    -
    -
    【例】
    -

    - 本サービスの利用状況を把握することにより、サービス向上及び利用者の興味やニーズにより適したサービスを提供するため -

    -
    sb-dpaoqrcfswnzknixwkll-auth-token.XSupabase Inc利用者のJavaScript Webトークン(JWT)およびリフレッシュトークン認証および認可に使用
    -
    - -

    - ※Google Analyticsにおけるデータ取り扱いの詳細はこちらをご確認ください -

    -
    - -
    -

    - 2024年8月1日 制定・施行 -

    -
    -
    -
    -
    - - diff --git a/src/routes/legal/privacy-policy/+page.svelte b/src/routes/legal/privacy-policy/+page.svelte deleted file mode 100644 index dc1e9b78..00000000 --- a/src/routes/legal/privacy-policy/+page.svelte +++ /dev/null @@ -1,234 +0,0 @@ - -
    -
    -
    -

    - プライバシーポリシー -

    -
    -
    -
    -
    -

    - 合同会社クロップウォッチ(以下「当社」といいます。)は、当社のサービスを利用する方(以下「利用者」といいます。)の個人情報の取扱いについて、以下のとおりプライバシーポリシー(以下「本ポリシー」といいます。)を定め、個人情報保護の仕組みを構築し、全従業員に個人情報保護の重要性を認識させるとともにその取組みを徹底させることにより、個人情報の保護を推進します。 -

    -
    -
    -

    - 第 1 条 (個人情報) -

    -

    - 「個人情報」とは、個人情報の保護に関する法律(平成十五年法律第五十七号、以下「個人情報保護法」といいます。)にいう「個人情報」を指し、生存する個人に関する情報であって、当該情報に含まれる氏名、生年月日その他の記述等により特定の個人を識別できるもの又は個人識別符号が含まれるものを指します。 -

    -
    -
    -

    - 第 2 条 (個人情報の利用目的) -

    -

    - 当社は、以下の目的に必要な範囲で、利用者の個人情報を取得し、取得した情報を利用させていただきます。以下の目的の範囲を超えて個人情報を利用する場合には、事前に適切な方法で利用者からの同意を得るものとします。 -

    -
      -
    • (1) 当社のサービス(以下「本サービス」といいます。)を提供するため
    • -
    • (2) 本サービスの内容を改良・改善し、又は新サービスを開発するため
    • -
    • - (3) - 本サービスの新機能、更新情報、キャンペーン等及び当社が提供する他のサービスのご案内(電子メール、チラシ、その他のダイレクトメールの送付を含みます。)のため -
    • -
    • (4) メンテナンス、重要なお知らせ等必要に応じたご連絡のため
    • -
    • - (5) - 本サービスに関する利用者からのご意見、お問い合わせ等に回答するため(本人確認を行うことを含みます。) -
    • -
    • (6) 本サービスの利用状況を利用者にご報告するため
    • -
    • - (7) - 本サービスに関するアンケート・取材等のご協力依頼や各種イベントへのご参加をお願いし、又はその結果等をご報告するため -
    • -
    • - (8) - 本サービスの利用履歴等を調査・分析し、その結果を本サービスの改良・開発や広告の配信に利用するため -
    • -
    • - (9) 利用者の承諾・申し込みに基づく、当社主催イベントの参加企業等への個人情報の提供 -
    • -
    • - (10) - 利用規約に違反した利用者や、不正・不当な目的で本サービスを利用しようとする利用者の特定をし、ご利用をお断りするため -
    • -
    -
    -
    -

    - 第 3 条 (クッキー等の利用) -

    -

    - 当社は、本サービスへの利用者のアクセス情報、閲覧情報等を取得するために、クッキー(Cookie)、情報収集モジュール等(以下「クッキー等」といいます。)の技術を使用しています。本サービスで利用しているクッキー等の内容については、当社が別途定めるクッキーポリシーをご確認ください。 -

    -
    -
    -

    - 第 4 条 (個人情報の管理と保護) -

    -

    - 個人情報の管理は、厳重に行うこととし、次に掲げるときを除き、利用者の同意がない限り、第三者に対し個人情報を開示・提供することはいたしません。また、安全性を考慮し、個人情報への不正アクセス、個人情報の紛失、破壊、改ざん及び漏えい等のリスクに対する予防並びに是正に関する対策を講じます。 -

    -
      -
    • - (1) - 人の生命、身体又は財産の保護のために必要がある場合であって、利用者の同意を得ることが困難であるとき。 -
    • -
    • - (2) - 公衆衛生の向上又は児童の健全な育成の推進のために特に必要がある場合であって、利用者の同意を得ることが困難であるとき。 -
    • -
    • - (3) - 国の機関若しくは地方公共団体又はその委託を受けた者が法令の定める事務を遂行することに対して協力する必要がある場合であって、利用者の同意を得ることにより当該事務の遂行に支障を及ぼすおそれがあるとき。 -
    • -
    • (4) その他法令で認められるとき。
    • -
    -
    -
    -

    - 第 5 条 (個人情報の取扱いの委託) -

    -

    - 当社は、利用目的の達成に必要な範囲内において、個人情報の取扱いの全部又は一部を委託する場合がございます。この場合、当社は、委託先としての適格性を十分審査するとともに、契約にあたって守秘義務に関する事項等を定め、委託先に対する必要かつ適切な監督を行います。 -

    -
    -
    -

    - 第 6 条 (個人情報の開示) -

    -

    - 利用者は、当社に対し、当社の保有する個人情報の開示を請求することができます。当社は、利用者から当該請求を受けたときは、利用者に対し、遅滞なくこれを開示します。ただし、開示することにより次のいずれかに該当する場合は、その全部又は一部を開示しないこともあり、開示しない決定をした場合には、その旨を遅滞なく通知します。 -

    -
      -
    • (1) 利用者又は第三者の生命、身体、財産その他の権利利益を害するおそれがある場合
    • -
    • (2) 当社の業務の適正な実施に著しい支障を及ぼすおそれがある場合
    • -
    • (3) その他法令に違反することとなる場合
    • -
    -
    -
    -

    - 第 7 条 (個人情報訂正等) -

    -

    - 1. - 利用者は、当社の保有する個人情報が誤った情報である場合には、当社に対し、当該個人情報の訂正、追加又は削除(以下「訂正等」といいます。)を請求することができます。
    - 2. 前項の請求を受けた場合、当社は遅滞なく必要な調査を行い、その結果前項の請求に理由があると判断した場合には、遅滞なく当該個人情報の訂正等を行います。
    3. 当社は、前項に基づき訂正等の実施・不実施について判断した場合には、遅滞なく、利用者ご本人に対してご連絡いたします。 -

    -
    -
    -

    - 第 8 条 (個人情報の利用停止等) -

    -

    - 1. - 利用者は、当社に対し、当社の保有する個人情報の利用の停止、消去又は第三者提供の停止(以下「利用停止等」といいます。)を請求することができます。
    - 2. 当社は、前項の請求を受けた場合には、必要な調査を行い、その結果前項の請求に理由があると判断した場合には、当該個人情報の利用停止等を行います。ただし、個人情報の利用停止等に多額の費用を要する場合その他利用停止等を行うことが困難な場合であって、利用者の権利利益を保護するために必要なこれに代わるべき措置をとれるときは、この代替策を講じます。
    3. 当社は、前項に基づき利用停止等の実施・不実施について判断した場合には、利用者ご本人に対してご連絡いたします。 -

    -
    -
    -

    - 第 9 条 (プライバシーポリシーの変更手続) -

    -

    - 当社は本ポリシーの内容を適宜見直し、その改善に努めます。本ポリシーの内容は、法令その他本ポリシーに別段の定めのある事項を除いて、変更することができるものとします。変更後のプライバシーポリシーは、当社所定の方法により、利用者に通知し、又は当社ウェブサイトに掲載したときから効力を生じるものとします。 -

    -
    -
    -

    - 第 10 条 (法令、規範の遵守) -

    -

    - 当社は、保有する個人情報に関して適用される日本の法令、その他規範を遵守します。 -

    -
    -
    -

    - 第 11 条 (苦情及び相談への対応) -

    -

    - 当社は、個人情報の取扱いに関する利用者からの苦情、相談を受け付け、適切かつ迅速に対応いたします。また、利用者からの当該個人情報の開示、訂正、追加、削除、利用又は提供の拒否等のご要望に対しても、迅速かつ適切に対応いたします。 -

    -
    -
    -

    - 第 12 条 (安全管理措置) -

    -

    - 当社が利用者よりお預かりした個人情報は、個人情報ファイルへのアクセス制限の実施、アクセスログの記録及び外部からの不正アクセス防止のためのセキュリティ対策の実施等、組織的、物理的、人的、技術的施策を講じることで個人情報への不正な侵入、個人情報の紛失、破壊、改ざん、及び漏えい等を防止いたします。万一、利用者の個人情報の漏えい等の事故が発生した場合、当社は、個人情報保護法及び関連するガイドラインに則り、速やかに監督官庁への報告を行うとともに、当該監督官庁の指示に従い、類似事案の発生防止措置及び再発防止措置等の必要な対応を行います。詳細については、別添「個人情報の安全管理措置」をご確認ください。 -

    -
    -
    -

    - 第 13 条 (当社住所・代表者氏名・個人情報保護管理者) -

    -

    - 当社住所、代表者及び個人情報保護管理者の氏名は以下のとおりです。
    - 住所:宮崎県西都市南方806-5
    代表者:池水 彩
    個人情報保護管理者:池水 彩 -

    -
    -
    -

    - 第 14 条 (お問い合わせ窓口) -

    -

    - 当社の個人情報の取扱いに関するお問い合せは以下までご連絡ください。
    - 合同会社クロップウォッチ
    〒881-0027 宮崎県西都市南方806-5
    TEL: 080-4284-3390
    - Mail: - sayaka@cropwatch.io -

    -

    - 2024年8月1日 制定・施行 -

    -
    -
    -
    -
    - - diff --git a/src/routes/legal/privacy-policy/+page.svelte.new b/src/routes/legal/privacy-policy/+page.svelte.new deleted file mode 100644 index 11e19c6a..00000000 --- a/src/routes/legal/privacy-policy/+page.svelte.new +++ /dev/null @@ -1,173 +0,0 @@ -
    -
    -
    -

    プライバシーポリシー

    -
    -
    -
    -
    -

    - 合同会社クロップウォッチ(以下「当社」といいます。)は、当社のサービスを利用する方(以下「利用者」といいます。)の個人情報の取扱いについて、以下のとおりプライバシーポリシー(以下「本ポリシー」といいます。)を定め、個人情報保護の仕組みを構築し、全従業員に個人情報保護の重要性を認識させるとともにその取組みを徹底させることにより、個人情報の保護を推進します。 -

    -
    -
    -

    第 1 条 (個人情報)

    -

    - 「個人情報」とは、個人情報の保護に関する法律(平成十五年法律第五十七号、以下「個人情報保護法」といいます。)にいう「個人情報」を指し、生存する個人に関する情報であって、当該情報に含まれる氏名、生年月日その他の記述等により特定の個人を識別できるもの又は個人識別符号が含まれるものを指します。 -

    -
    -
    -

    第 2 条 (個人情報の利用目的)

    -

    - 当社は、以下の目的に必要な範囲で、利用者の個人情報を取得し、取得した情報を利用させていただきます。以下の目的の範囲を超えて個人情報を利用する場合には、事前に適切な方法で利用者からの同意を得るものとします。 -

    -
      -
    • - (1) 当社のサービス(以下「本サービス」といいます。)を提供するため -
    • -
    • - (2) 本サービスの内容を改良・改善し、又は新サービスを開発するため -
    • -
    • - (3) - 本サービスの新機能、更新情報、キャンペーン等及び当社が提供する他のサービスのご案内(電子メール、チラシ、その他のダイレクトメールの送付を含みます。)のため -
    • -
    • (4) メンテナンス、重要なお知らせ等必要に応じたご連絡のため
    • -
    • - (5) - 本サービスに関する利用者からのご意見、お問い合わせ等に回答するため(本人確認を行うことを含みます。) -
    • -
    • (6) 本サービスの利用状況を利用者にご報告するため
    • -
    • - (7) - 本サービスに関するアンケート・取材等のご協力依頼や各種イベントへのご参加をお願いし、又はその結果等をご報告するため -
    • -
    • - (8) - 本サービスの利用履歴等を調査・分析し、その結果を本サービスの改良・開発や広告の配信に利用するため -
    • -
    • - (9) 利用者の承諾・申し込みに基づく、当社主催イベントの参加企業等への個人情報の提供 -
    • -
    • - (10) - 利用規約に違反した利用者や、不正・不当な目的で本サービスを利用しようとする利用者の特定をし、ご利用をお断りするため -
    • -
    -
    -
    -

    第 3 条 (クッキー等の利用)

    -

    - 当社は、本サービスへの利用者のアクセス情報、閲覧情報等を取得するために、クッキー(Cookie)、情報収集モジュール等(以下「クッキー等」といいます。)の技術を使用しています。本サービスで利用しているクッキー等の内容については、当社が別途定めるクッキーポリシーをご確認ください。 -

    -
    -
    -

    第 4 条 (個人情報の管理と保護)

    -

    - 個人情報の管理は、厳重に行うこととし、次に掲げるときを除き、利用者の同意がない限り、第三者に対し個人情報を開示・提供することはいたしません。また、安全性を考慮し、個人情報への不正アクセス、個人情報の紛失、破壊、改ざん及び漏えい等のリスクに対する予防並びに是正に関する対策を講じます。 -

    -
      -
    • - (1) - 人の生命、身体又は財産の保護のために必要がある場合であって、利用者の同意を得ることが困難であるとき。 -
    • -
    • - (2) - 公衆衛生の向上又は児童の健全な育成の推進のために特に必要がある場合であって、利用者の同意を得ることが困難であるとき。 -
    • -
    • - (3) - 国の機関若しくは地方公共団体又はその委託を受けた者が法令の定める事務を遂行することに対して協力する必要がある場合であって、利用者の同意を得ることにより当該事務の遂行に支障を及ぼすおそれがあるとき。 -
    • -
    • (4) その他法令で認められるとき。
    • -
    -
    -
    -

    第 5 条 (個人情報の取扱いの委託)

    -

    - 当社は、利用目的の達成に必要な範囲内において、個人情報の取扱いの全部又は一部を委託する場合がございます。この場合、当社は、委託先としての適格性を十分審査するとともに、契約にあたって守秘義務に関する事項等を定め、委託先に対する必要かつ適切な監督を行います。 -

    -
    -
    -

    第 6 条 (個人情報の開示)

    -

    - 利用者は、当社に対し、当社の保有する個人情報の開示を請求することができます。当社は、利用者から当該請求を受けたときは、利用者に対し、遅滞なくこれを開示します。ただし、開示することにより次のいずれかに該当する場合は、その全部又は一部を開示しないこともあり、開示しない決定をした場合には、その旨を遅滞なく通知します。 -

    -
      -
    • - (1) 利用者又は第三者の生命、身体、財産その他の権利利益を害するおそれがある場合 -
    • -
    • (2) 当社の業務の適正な実施に著しい支障を及ぼすおそれがある場合
    • -
    • (3) その他法令に違反することとなる場合
    • -
    -
    -
    -

    第 7 条 (個人情報訂正等)

    -

    - 1. - 利用者は、当社の保有する個人情報が誤った情報である場合には、当社に対し、当該個人情報の訂正、追加又は削除(以下「訂正等」といいます。)を請求することができます。
    - 2. 前項の請求を受けた場合、当社は遅滞なく必要な調査を行い、その結果前項の請求に理由があると判断した場合には、遅滞なく当該個人情報の訂正等を行います。
    3. 当社は、前項に基づき訂正等の実施・不実施について判断した場合には、遅滞なく、利用者ご本人に対してご連絡いたします。 -

    -
    -
    -

    第 8 条 (個人情報の利用停止等)

    -

    - 1. - 利用者は、当社に対し、当社の保有する個人情報の利用の停止、消去又は第三者提供の停止(以下「利用停止等」といいます。)を請求することができます。
    - 2. 当社は、前項の請求を受けた場合には、必要な調査を行い、その結果前項の請求に理由があると判断した場合には、当該個人情報の利用停止等を行います。ただし、個人情報の利用停止等に多額の費用を要する場合その他利用停止等を行うことが困難な場合であって、利用者の権利利益を保護するために必要なこれに代わるべき措置をとれるときは、この代替策を講じます。
    3. 当社は、前項に基づき利用停止等の実施・不実施について判断した場合には、利用者ご本人に対してご連絡いたします。 -

    -
    -
    -

    第 9 条 (プライバシーポリシーの変更手続)

    -

    - 当社は本ポリシーの内容を適宜見直し、その改善に努めます。本ポリシーの内容は、法令その他本ポリシーに別段の定めのある事項を除いて、変更することができるものとします。変更後のプライバシーポリシーは、当社所定の方法により、利用者に通知し、又は当社ウェブサイトに掲載したときから効力を生じるものとします。 -

    -
    -
    -

    第 10 条 (法令、規範の遵守)

    -

    - 当社は、保有する個人情報に関して適用される日本の法令、その他規範を遵守します。 -

    -
    -
    -

    第 11 条 (苦情及び相談への対応)

    -

    - 当社は、個人情報の取扱いに関する利用者からの苦情、相談を受け付け、適切かつ迅速に対応いたします。また、利用者からの当該個人情報の開示、訂正、追加、削除、利用又は提供の拒否等のご要望に対しても、迅速かつ適切に対応いたします。 -

    -
    -
    -

    第 12 条 (安全管理措置)

    -

    - 当社が利用者よりお預かりした個人情報は、個人情報ファイルへのアクセス制限の実施、アクセスログの記録及び外部からの不正アクセス防止のためのセキュリティ対策の実施等、組織的、物理的、人的、技術的施策を講じることで個人情報への不正な侵入、個人情報の紛失、破壊、改ざん、及び漏えい等を防止いたします。万一、利用者の個人情報の漏えい等の事故が発生した場合、当社は、個人情報保護法及び関連するガイドラインに則り、速やかに監督官庁への報告を行うとともに、当該監督官庁の指示に従い、類似事案の発生防止措置及び再発防止措置等の必要な対応を行います。詳細については、別添「個人情報の安全管理措置」をご確認ください。 -

    -
    -
    -

    第 13 条 (当社住所・代表者氏名・個人情報保護管理者)

    -

    - 当社住所、代表者及び個人情報保護管理者の氏名は以下のとおりです。
    - 住所:宮崎県西都市南方806-5
    代表者:池水 彩
    個人情報保護管理者:池水 彩 -

    -
    -
    -

    第 14 条 (お問い合わせ窓口)

    -

    - 当社の個人情報の取扱いに関するお問い合せは以下までご連絡ください。
    - 合同会社クロップウォッチ
    〒881-0027 宮崎県西都市南方806-5
    TEL: 080-4284-3390
    - Mail: sayaka@cropwatch.io -

    -

    2024年8月1日 制定・施行

    -
    -
    -
    -
    - - diff --git a/src/routes/locations/+page.server.ts b/src/routes/locations/+page.server.ts new file mode 100644 index 00000000..998d49db --- /dev/null +++ b/src/routes/locations/+page.server.ts @@ -0,0 +1,29 @@ +import { ApiService } from "$lib/api/api.service"; +import type { LayoutServerLoad } from "../$types"; + +export const load: LayoutServerLoad = async ({ locals, fetch }) => { + const session = locals.jwt ?? null; + const authToken = locals.jwtString ?? null; + + if (!authToken) { + return { + session, + devices: [], + totalDeviceCount: 0, + triggeredRulesCount: 0 + }; + } + + const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken + }); + + const allLocations = await apiServiceInstance.getAllLocations().then((res) => res.data ?? []).catch(() => []); + + return { + session, + authToken, + allLocations, + }; +} \ No newline at end of file diff --git a/src/routes/locations/+page.svelte b/src/routes/locations/+page.svelte new file mode 100644 index 00000000..b14d80a6 --- /dev/null +++ b/src/routes/locations/+page.svelte @@ -0,0 +1,107 @@ + + + goto('/')}> + ← {m.action_back_to_dashboard()} + +
    + + + {#snippet toolbarActions()} + goto('/locations/create')}> + + + {/snippet} + + {#snippet rowActions(row: LocationDto)} + handleViewLocation(row)}> + + + {/snippet} + + +
    + + diff --git a/src/routes/locations/[location_id]/+page.server.ts b/src/routes/locations/[location_id]/+page.server.ts new file mode 100644 index 00000000..5d63b438 --- /dev/null +++ b/src/routes/locations/[location_id]/+page.server.ts @@ -0,0 +1,42 @@ +import { ApiService } from '$lib/api/api.service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const locationId = Number.parseInt(params.location_id, 10); + + if (!authToken || !Number.isFinite(locationId)) { + return { + allLocationDevices: [], + currentLocation: null + }; + } + + const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken + }); + + const [allLocationDevices, currentLocation] = await Promise.all([ + apiServiceInstance + .getAllLocationDevices(locationId) + .then((res) => res.data ?? []) + .catch(() => []), + apiServiceInstance + .getLocation(locationId) + .then((location) => location ?? null) + .catch(() => null), + ]); + + let hasSettings = false; + if (currentLocation) { + + } + + return { + allLocationDevices, + currentLocation: currentLocation?.name ?? 'Not Found', + location: currentLocation, + hasSettings, + }; +}; diff --git a/src/routes/locations/[location_id]/+page.svelte b/src/routes/locations/[location_id]/+page.svelte new file mode 100644 index 00000000..b645f6f5 --- /dev/null +++ b/src/routes/locations/[location_id]/+page.svelte @@ -0,0 +1,206 @@ + + + goto(`/`)}>← {m.action_back_to_dashboard()} + +
    + + + {#snippet toolbarActions()} +
    + {#if selectedLocationId} + + goto(`/locations/${encodeURIComponent(+selectedLocationId)}/devices/create`)} + > + + {m.devices_add_device()} + + goto(`/locations/${encodeURIComponent(+selectedLocationId)}/settings`)} + > + + + {/if} +
    + {/snippet} + + {#snippet cell( + row: LocationDeviceRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'dev_eui'} + + {row.dev_eui} + + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet rowActions(row: LocationDeviceRow)} +
    + handleViewDevice(row)}> + + + {#if data.hasSettings} + { + if (!selectedLocationId) return; + + goto( + `/locations/${encodeURIComponent(selectedLocationId)}/devices/${encodeURIComponent(row.dev_eui)}/settings` + ); + }} + > + + + {/if} +
    + {/snippet} +
    +
    +
    + + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts new file mode 100644 index 00000000..2d3f53f2 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts @@ -0,0 +1,63 @@ +import { ApiService } from '$lib/api/api.service'; +import type { LayoutServerLoad } from './$types'; + +/** + * Device-level layout data loader. + * + * Resolves the device's metadata (including which data table it belongs to) + * and exposes it to every child route (+page, settings, etc.) via `parent()`. + * + * The `dataTable` string (e.g. 'cw_air_data', 'cw_traffic2') drives which + * display component the dispatcher page renders. + */ +export const load: LayoutServerLoad = async ({ params, locals }) => { + const authToken = locals.jwtString ?? null; + const userId = locals.jwt?.sub ?? null; + const { dev_eui, location_id } = params; + + if (!authToken || !dev_eui) { + return { + devEui: dev_eui ?? '', + locationId: location_id ?? '', + device: null, + dataTable: null as string | null, + permissionLevel: 4 + }; + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + + try { + const locationId = Number.parseInt(location_id, 10); + const [device, location] = await Promise.all([ + api.getDevice(dev_eui), + Number.isFinite(locationId) ? api.getLocation(locationId).catch(() => null) : Promise.resolve(null) + ]); + + const owners = Array.isArray(location?.cw_location_owners) ? location.cw_location_owners : []; + const currentOwner = userId ? owners.find((o) => o.user_id === userId) : null; + const permissionLevel = Number(currentOwner?.permission_level) || 4; + + // The API response includes a `data_table` field (string) that identifies + // which Supabase table this device stores telemetry in. Fall back to null + // so the dispatcher uses DefaultDisplay. + const deviceDataTable = device.cw_device_type.data_table_v2; + + return { + devEui: dev_eui, + locationId: location_id, + device, + dataTable: deviceDataTable, + permissionLevel + }; + } catch (err) { + console.error(`[layout] Failed to load device ${dev_eui}:`, err); + return { + devEui: dev_eui, + locationId: location_id, + device: null, + dataTable: null as string | null, + permissionLevel: 4 + }; + } +}; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.svelte new file mode 100644 index 00000000..c7c07e77 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children()} +
    + + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts new file mode 100644 index 00000000..f4e2d3a8 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.server.ts @@ -0,0 +1,110 @@ +import { ApiServiceError, type PaginationQuery, ApiService } from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadLatestDeviceDataOrNull( + api: ApiService, + devEui: string +): Promise | null> { + try { + return await api.getDeviceLatestData(devEui, { suppressNotFoundError: true }); + } catch (error) { + if (error instanceof ApiServiceError && error.status === 404) { + return null; + } + + throw error; + } +} + +export const load: PageServerLoad = async ({ params, fetch, parent }) => { + const parentData = await parent(); + const authToken = parentData.authToken ?? null; + const locationId = Number.parseInt(params.location_id, 10); + + if (!authToken || !Number.isFinite(locationId)) { + return { + deviceData: [], + latestData: null + }; + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + const pagination: PaginationQuery = { + skip: 0, + take: 144 + }; + + const [deviceData, latestData] = await Promise.all([ + api.getDeviceData(params.dev_eui, pagination), + loadLatestDeviceDataOrNull(api, params.dev_eui) + ]); + + return { + deviceData: deviceData ?? [], + latestData: latestData ?? null + }; +}; + +export const actions: Actions = { + saveDataNote: async ({ request, fetch, locals }) => { + const authToken = locals.jwtString ?? null; + const data = await request.formData(); + const noteContent = data.get('note')?.toString() ?? ''; + const telemetryId = data.get('created_at')?.toString() ?? ''; + const devEui = data.get('dev_eui')?.toString() ?? ''; + + if (!authToken) { + return fail(401, { + action: 'saveDataNote', + message: m.devices_save_note_requires_login() + }); + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await api.createAirNote({ + note: noteContent, + created_at: telemetryId, + dev_eui: devEui + }); + } catch (error) { + const responsePayload = error instanceof ApiServiceError ? error.payload : error; + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'saveDataNote', + message: readApiError(responsePayload, m.devices_save_note_failed()) + }); + } + + return { + success: true, + message: m.devices_save_note_success() + }; + } +}; + +function readApiError(payload: unknown, fallback: string): string { + if (payload && typeof payload === 'object') { + const payloadRecord = payload as Record; + const message = payloadRecord.message; + if (typeof message === 'string' && message.length > 0) return message; + if (Array.isArray(message)) { + const text = message + .filter((m): m is string => typeof m === 'string' && m.length > 0) + .join(', '); + if (text) return text; + } + + return readApiError(payloadRecord.payload, fallback); + } + return fallback; +} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte new file mode 100644 index 00000000..8a6290ab --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -0,0 +1,694 @@ + + + + {m.devices_dashboard_page_title({ devEui: devEui.toUpperCase() })} + + +
    + goto(resolve('/locations/[location_id]', { location_id: locationId }))} + > + ← {m.devices_back_to_location()} + + goto(resolve('/'))} + > + ← {m.action_back_to_dashboard()} + +
    + +
    + + {#snippet actions()} +

    + {m.display_last_updated()}: + {#if readCreatedAt(displayCurrentRecord)} + + {:else} + Data Not available + {/if} +

    + {/snippet} +
    +
    + {#if data.device?.cw_device_type.name !== '[CROPWATCH] Nvidia Jetson'} + {#each getRangeOptions() as ranges (ranges.value)} + selectRange(ranges.value)} + > + {ranges.label} + + {/each} + {/if} +
    + + +
    +
    + {#if permissionLevel <= 2} + + + {#if data.device?.cw_device_type.name === '[CROPWATCH] Nvidia Jetson'} + + {/if} + {/if} + + {#if permissionLevel == 1} + + goto( + resolve('/locations/[location_id]/devices/[dev_eui]/settings', { + location_id: locationId, + dev_eui: devEui + }) + )} + > + + {m.nav_settings()} + + {/if} +
    +
    + + {#if fetching || fetchError} +
    + {#if fetching} +
    + + {m.devices_loading_telemetry()} +
    + {/if} + + {#if fetchError} +

    {fetchError}

    + {/if} +
    + {/if} +
    + + {#if noDataYet} + +

    {m.devices_no_data_yet()}

    +
    + {:else} +
    + +
    + {/if} +
    + + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte new file mode 100644 index 00000000..2e961fa4 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/ViewNoteHistoryDialog.svelte @@ -0,0 +1,12 @@ + + + (open = true)}> + + Notes + \ No newline at end of file diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.spec.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.spec.ts new file mode 100644 index 00000000..4d7a427e --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + formatTimestampForTimeZone, + resolveCsvRequestRange, + resolveExportTimeZone +} from './csvExport'; + +describe('csvExport timezone helpers', () => { + it('resolves the export timezone from nested device location data', () => { + expect( + resolveExportTimeZone({ + cw_locations: { + timezone: 'America/New_York' + } + }) + ).toBe('America/New_York'); + }); + + it('converts selected wall-clock range values into UTC request bounds', () => { + const range = resolveCsvRequestRange( + { + start: new Date(2026, 2, 8), + end: new Date(2026, 2, 8), + startTime: { hours: 8, minutes: 0 }, + endTime: { hours: 9, minutes: 30 } + }, + 'America/New_York' + ); + + expect(range).toEqual({ + start: '2026-03-08T12:00:00.000Z', + end: '2026-03-08T13:30:59.999Z' + }); + }); + + it('formats API timestamps in the resolved export timezone', () => { + expect(formatTimestampForTimeZone('2026-03-08T12:00:00.000Z', 'America/New_York')).toBe( + '2026-03-08T08:00:00.000-04:00' + ); + }); +}); diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts new file mode 100644 index 00000000..891fe393 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts @@ -0,0 +1,353 @@ +export type CsvRow = Record; + +export interface CsvExportRangeSelection { + start: Date; + end: Date; + startTime?: { + hours: number; + minutes: number; + }; + endTime?: { + hours: number; + minutes: number; + }; +} + +interface DownloadCsvOptions { + locationName: string; + devEui: string; + rangeLabel: string; + timeZone?: string; +} + +const CREATED_AT_KEY = 'created_at'; +const TIMESTAMP_COLUMN_NAMES = new Set(['created_at', 'timestamp', 'traffic_hour']); +const MINUTES_PER_HOUR = 60; +const MILLISECONDS_PER_MINUTE = 60_000; +const TIMEZONE_SUFFIX_PATTERN = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/; + +export function downloadCsv(rows: CsvRow[], options: DownloadCsvOptions): void { + if (rows.length === 0) { + return; + } + + const columns = getColumns(rows); + const records = rows.map((row) => + columns.map((column) => escapeCsvValue(formatCsvValue(column, row[column], options.timeZone))) + ); + const csv = [columns.join(','), ...records.map((record) => record.join(','))].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const safeLocationName = sanitizeFileName(options.locationName); + const safeDevEui = sanitizeFileName(options.devEui); + + link.href = url; + link.download = `${safeLocationName}-${safeDevEui}-${options.rangeLabel}-data.csv`; + document.body.append(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(url); +} + +export function resolveExportTimeZone(value: unknown): string { + const resolved = + readTimeZone(value) ?? + readTimeZone(readRecord(value)?.cw_locations) ?? + readTimeZone(readRecord(value)?.location) ?? + readBrowserTimeZone(); + + if (resolved && isValidTimeZone(resolved)) { + return resolved; + } + + const browserTimeZone = readBrowserTimeZone(); + return browserTimeZone && isValidTimeZone(browserTimeZone) ? browserTimeZone : 'UTC'; +} + +export function resolveCsvRequestRange( + selection: CsvExportRangeSelection, + timeZone: string +): { start: string; end: string } { + const startDate = zonedDateTimeToUtc( + selection.start, + selection.startTime?.hours ?? 0, + selection.startTime?.minutes ?? 0, + 0, + 0, + timeZone + ); + const endDate = zonedDateTimeToUtc( + selection.end, + selection.endTime?.hours ?? 23, + selection.endTime?.minutes ?? 59, + 59, + 999, + timeZone + ); + + return { + start: startDate.toISOString(), + end: endDate.toISOString() + }; +} + +export function formatCsvRangeLabel( + range: { start: Date | string; end: Date | string }, + timeZone: string +): string { + const startDate = normalizeDate(range.start); + const endDate = normalizeDate(range.end); + const safeTimeZone = sanitizeFileName(timeZone.replaceAll('/', '-')); + + if (!startDate || !endDate) { + return safeTimeZone || 'custom'; + } + + return `${formatFileDateTime(startDate, timeZone)}_to_${formatFileDateTime(endDate, timeZone)}_${safeTimeZone}`; +} + +export function formatTimestampForTimeZone(value: string, timeZone: string): string { + const parsed = parseTimestamp(value); + if (!parsed) { + return value; + } + + return formatDateForTimeZone(parsed, timeZone); +} + +function escapeCsvValue(value: string | number): string { + const normalized = String(value); + if (/[,"\n]/.test(normalized)) { + return `"${normalized.replace(/"/g, '""')}"`; + } + return normalized; +} + +function formatCsvValue(column: string, value: unknown, timeZone?: string): string | number { + if (value == null) { + return ''; + } + + if (value instanceof Date) { + return timeZone ? formatDateForTimeZone(value, timeZone) : value.toISOString(); + } + + if (typeof value === 'string' && timeZone && isTimestampColumn(column)) { + return formatTimestampForTimeZone(value, timeZone); + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return typeof value === 'number' ? value : String(value); +} + +function getColumns(rows: CsvRow[]): string[] { + const keys = new Set(); + + for (const row of rows) { + for (const key of Object.keys(row)) { + keys.add(key); + } + } + + const preferredOrder = ['created_at', 'traffic_hour', 'timestamp', 'id', 'dev_eui']; + const ordered = preferredOrder.filter((key) => keys.delete(key) || false); + const remaining = Array.from(keys).sort((a, b) => a.localeCompare(b)); + + return [...ordered, ...remaining]; +} + +function sanitizeFileName(value: string): string { + const normalized = value.trim(); + return normalized.length > 0 ? normalized.replace(/[^a-zA-Z0-9_-]/g, '_') : 'device'; +} + +function readRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readTimeZone(value: unknown): string | null { + if (Array.isArray(value)) { + for (const entry of value) { + const nestedTimeZone = readTimeZone(entry); + if (nestedTimeZone) { + return nestedTimeZone; + } + } + + return null; + } + + const record = readRecord(value); + if (!record) { + return null; + } + + return ( + readString(record.timezone) ?? + readString(record.time_zone) ?? + readString(record.iana_timezone) ?? + readString(record.ianaTimeZone) ?? + null + ); +} + +function readBrowserTimeZone(): string | null { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? null; + } catch { + return null; + } +} + +function isValidTimeZone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date()); + return true; + } catch { + return false; + } +} + +function isTimestampColumn(column: string): boolean { + const normalized = column.toLowerCase(); + return ( + TIMESTAMP_COLUMN_NAMES.has(normalized) || + normalized === CREATED_AT_KEY || + normalized.endsWith('_at') || + normalized.endsWith('_time') + ); +} + +function normalizeDate(value: Date | string): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + return parseTimestamp(value); +} + +function padDatePart(value: number, size = 2): string { + return String(value).padStart(size, '0'); +} + +function normalizeTimestampInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + if (TIMEZONE_SUFFIX_PATTERN.test(trimmed)) return trimmed; + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return `${trimmed}T00:00:00.000Z`; + } + + const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T'); + return `${normalized}Z`; +} + +function parseTimestamp(value: string): Date | null { + const parsed = new Date(normalizeTimestampInput(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function getTimeZoneOffsetMilliseconds(date: Date, timeZone: string): number { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }); + const parts = formatter.formatToParts(date); + const values = Object.fromEntries( + parts + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, Number.parseInt(part.value, 10)]) + ); + const asUtc = Date.UTC( + values.year, + values.month - 1, + values.day, + values.hour, + values.minute, + values.second, + date.getUTCMilliseconds() + ); + + return asUtc - date.getTime(); +} + +function formatOffset(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? '+' : '-'; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteMinutes / MINUTES_PER_HOUR); + const minutes = absoluteMinutes % MINUTES_PER_HOUR; + return `${sign}${padDatePart(hours)}:${padDatePart(minutes)}`; +} + +function formatDateForTimeZone(date: Date, timeZone: string): string { + const offsetMinutes = Math.round( + getTimeZoneOffsetMilliseconds(date, timeZone) / MILLISECONDS_PER_MINUTE + ); + const shiftedDate = new Date(date.getTime() + offsetMinutes * MILLISECONDS_PER_MINUTE); + + return [ + `${shiftedDate.getUTCFullYear()}-${padDatePart(shiftedDate.getUTCMonth() + 1)}-${padDatePart(shiftedDate.getUTCDate())}`, + `${padDatePart(shiftedDate.getUTCHours())}:${padDatePart(shiftedDate.getUTCMinutes())}:${padDatePart(shiftedDate.getUTCSeconds())}.${padDatePart(shiftedDate.getUTCMilliseconds(), 3)}${formatOffset(offsetMinutes)}` + ].join('T'); +} + +function formatFileDateTime(date: Date, timeZone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }); + const parts = formatter.formatToParts(date); + const values = Object.fromEntries( + parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]) + ); + + return `${values.year}${values.month}${values.day}-${values.hour}${values.minute}`; +} + +function zonedDateTimeToUtc( + date: Date, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, + timeZone: string +): Date { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const utcGuess = Date.UTC(year, month, day, hours, minutes, seconds, milliseconds); + let offset = getTimeZoneOffsetMilliseconds(new Date(utcGuess), timeZone); + let resolvedTime = utcGuess - offset; + const adjustedOffset = getTimeZoneOffsetMilliseconds(new Date(resolvedTime), timeZone); + + if (adjustedOffset !== offset) { + offset = adjustedOffset; + resolvedTime = utcGuess - offset; + } + + return new Date(resolvedTime); +} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte new file mode 100644 index 00000000..5af28130 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExportDialog.svelte @@ -0,0 +1,247 @@ + + + + + {m.devices_csv_export()} + + + +
    + + +

    + {m.devices_export_timezone_hint({ timeZone })} +

    + + {#if exportError} +

    {exportError}

    + {/if} +
    + {#snippet actions()} + Cancel + + {m.action_download()} CSV + + {/snippet} +
    + + diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte new file mode 100644 index 00000000..aae07e09 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvTrafficExportDialog.svelte @@ -0,0 +1,178 @@ + + + + + {m.devices_export_traffic_button()} + + + +
    + + + + + {#if exportError} +

    {exportError}

    + {/if} +
    + {#snippet actions()} + + + {m.action_cancel()} + + + + {m.action_download()} CSV + + {/snippet} +
    + + \ No newline at end of file diff --git a/create-test-user.mjs b/src/routes/locations/[location_id]/devices/[dev_eui]/dataDiscriminator.ts similarity index 100% rename from create-test-user.mjs rename to src/routes/locations/[location_id]/devices/[dev_eui]/dataDiscriminator.ts diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/devices-page.css b/src/routes/locations/[location_id]/devices/[dev_eui]/devices-page.css new file mode 100644 index 00000000..4f6dbcaf --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/devices-page.css @@ -0,0 +1,87 @@ + .device-dashboard { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: + radial-gradient(circle at top right, rgba(14, 165, 233, 0.1), transparent 38%), + radial-gradient(circle at bottom left, rgba(34, 197, 94, 0.1), transparent 40%); + } + + .dashboard-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .dashboard-toolbar__primary, + .dashboard-toolbar__ranges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + } + + .kpi-value { + margin: 0 0 0.75rem; + font-size: clamp(1.45rem, 2.1vw, 2rem); + font-weight: 700; + + color: var(--cw-text-primary); + } + + .kpi-value span:first { + margin-left: 0.35rem; + letter-spacing: 0.02em; + } + + .kpi-value span { + margin-left: 0.35rem; + font-size: 0.9rem; + font-weight: 500; + letter-spacing: -0.02em; + color: var(--cw-text-muted); + } + + .chart-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 1rem; + } + + .table-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + @media (max-width: 1100px) { + .kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .chart-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 640px) { + .device-dashboard { + padding: 0.5rem; + } + + .kpi-grid { + grid-template-columns: 1fr; + } + + .dashboard-toolbar { + align-items: stretch; + } + } \ No newline at end of file diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts new file mode 100644 index 00000000..8609df27 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.server.ts @@ -0,0 +1,416 @@ +import { env } from '$env/dynamic/private'; +import { + ApiService, + ApiServiceError, + type DeviceDto, + type LocationDto +} from '$lib/api/api.service'; +import { + isValidTtiDeviceId, + normalizeTtiDeviceId, + TTI_DEVICE_ID_MAX_LENGTH +} from '$lib/devices/tti-device-id'; +import { m } from '$lib/paraglide/messages.js'; +import { fail, type Actions } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const DEVICE_NAME_MAX_LENGTH = 120; +const DEVICE_GROUP_MAX_LENGTH = 120; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const VALID_PERMISSION_LEVELS = new Set([1, 2, 3, 4]); + +type DeviceFormValues = { + name: string; + group: string; + location_id: number; + tti_name: string; +}; + +type DeviceOwnerPermissionValues = { + ownerKey: string; + targetUserEmail: string; + permissionLevel: string; +}; + +type OwnerIdentity = { + email: string; + name: string; +}; + +type NormalizedDeviceOwner = { + id: number; + key: string; + name: string; + email: string; + userId: string; + permissionLevel: number; +}; + +type SensorCertificateRow = { + key: 'sensor' | 'sensor2'; + label: string; + serial: string; + product: string; + downloadDisabledReason: string | null; +}; + +const readString = (value: FormDataEntryValue | null): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +function readApiMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === 'object') { + const message = (payload as Record).message; + if (Array.isArray(message)) { + const combined = message + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .join(', '); + if (combined.length > 0) return combined; + } + + if (typeof message === 'string' && message.trim().length > 0) { + return message.trim(); + } + } + + if (typeof payload === 'string' && payload.trim().length > 0) { + return payload.trim(); + } + + return fallback; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readProfileIdentity(record: Record): OwnerIdentity { + const profiles = record.profiles as Record | undefined; + return { + email: str(record.email) || str(record.user_email) || str(profiles?.email), + name: + str(record.name) || + str(record.full_name) || + str(profiles?.full_name) || + str(profiles?.display_name) + }; +} + +function buildLocationOwnerIdentityMap(location: LocationDto | null): Map { + const identities = new Map(); + const owners = location?.cw_location_owners ?? []; + + for (const owner of owners) { + const userId = typeof owner.user_id === 'string' ? owner.user_id.trim() : ''; + if (!userId) continue; + + const existing = identities.get(userId) ?? { email: '', name: '' }; + const profile = readProfileIdentity(owner as Record); + identities.set(userId, { + email: profile.email || existing.email, + name: profile.name || existing.name + }); + } + + return identities; +} + +function normalizeDeviceOwners( + device: DeviceDto | null, + locationOwnerIdentities: Map +): NormalizedDeviceOwner[] { + const owners = device?.cw_device_owners ?? []; + + return owners + .map((owner, index) => { + const userId = str(owner.user_id); + const locationOwnerIdentity = locationOwnerIdentities.get(userId); + const ownerIdentity = readProfileIdentity(owner); + const email = + ownerIdentity.email || str(owner.targetUserEmail) || locationOwnerIdentity?.email || ''; + const name = + ownerIdentity.name || + locationOwnerIdentity?.name || + userId || + m.devices_owner_fallback_name({ index: String(index + 1) }); + const rawId = owner.id; + const id = typeof rawId === 'number' && Number.isFinite(rawId) ? rawId : index + 1; + const rawPerm = owner.permission_level; + const permissionLevel = typeof rawPerm === 'number' && Number.isFinite(rawPerm) ? rawPerm : 4; + + return { + id, + key: email || userId || String(id), + name, + email, + userId, + permissionLevel + } satisfies NormalizedDeviceOwner; + }) + .sort((left, right) => + `${left.name} ${left.email} ${left.userId}`.localeCompare( + `${right.name} ${right.email} ${right.userId}` + ) + ); +} + +function buildSensorCertificateRows(device: DeviceDto | null): SensorCertificateRow[] { + if (!device) return []; + + const product = str(device.cw_device_type?.model); + const hasApiToken = str(env.PRIVATE_LIBELLUS_API_TOKEN).length > 0; + const hasBaseUrl = str(env.PRIVATE_LIBELLUS_BASE_URL).length > 0; + const record = device as Record; + const rows = [ + { + key: 'sensor' as const, + label: m.devices_sensor_one(), + serial: str(record.sensor1_serial) || str(record.sensor_serial) + }, + { + key: 'sensor2' as const, + label: m.devices_sensor_two(), + serial: str(record.sensor2_serial) + } + ]; + + return rows + .filter((row) => row.serial.length > 0) + .map((row) => ({ + ...row, + product, + downloadDisabledReason: + row.key !== 'sensor' + ? null + : !hasApiToken + ? m.devices_libellus_api_token_missing() + : !hasBaseUrl + ? m.devices_libellus_base_url_missing() + : !product + ? m.devices_libellus_product_name_missing() + : null + })); +} + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const devEui = String(params.dev_eui ?? '').trim(); + const locationId = Number.parseInt(params.location_id ?? '', 10); + + if (!authToken || !devEui) { + return { + devEui, + deviceName: '', + deviceGroup: '', + ttiName: '', + deviceGroups: [] as string[], + sensorCertificates: [] as SensorCertificateRow[], + deviceOwners: [] as NormalizedDeviceOwner[] + }; + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + const [device, deviceGroups, location, locations] = await Promise.all([ + api.getDevice(devEui).catch(() => null), + api.getDeviceGroups().catch(() => []), + Number.isFinite(locationId) + ? api.getLocation(locationId).catch(() => null) + : Promise.resolve(null), + api.getLocations().catch(() => []) + ]); + + const locationOwnerIdentities = buildLocationOwnerIdentityMap(location); + const deviceName = device?.name || devEui.toUpperCase(); + const sensorCertificates = buildSensorCertificateRows(device); + + return { + devEui, + deviceName, + location_id: device?.location_id, + deviceGroup: device?.group || '', + ttiName: normalizeTtiDeviceId(str(device?.tti_name)), + deviceGroups, + locations, + sensorCertificates, + deviceOwners: normalizeDeviceOwners(device, locationOwnerIdentities) + }; +}; + +export const actions: Actions = { + updateDevice: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const devEui = String(params.dev_eui ?? '').trim(); + + if (!authToken) { + return fail(401, { + action: 'updateDevice', + message: m.devices_update_requires_login() + }); + } + + if (!devEui) { + return fail(400, { + action: 'updateDevice', + message: m.devices_invalid_device_id() + }); + } + + const formData = await request.formData(); + const values: DeviceFormValues = { + name: readString(formData.get('name')), + group: readString(formData.get('group')), + location_id: Number.parseInt(readString(formData.get('location_id')), 10), + tti_name: normalizeTtiDeviceId(readString(formData.get('tti_name'))) + }; + + const fieldErrors: Partial> = {}; + + if (!values.name) { + fieldErrors.name = m.devices_device_name_required(); + } else if (values.name.length > DEVICE_NAME_MAX_LENGTH) { + fieldErrors.name = m.devices_device_name_length({ max: String(DEVICE_NAME_MAX_LENGTH) }); + } + + if (values.group.length > DEVICE_GROUP_MAX_LENGTH) { + fieldErrors.group = m.devices_device_group_length({ + max: String(DEVICE_GROUP_MAX_LENGTH) + }); + } + + if ( + values.tti_name.length > TTI_DEVICE_ID_MAX_LENGTH || + (values.tti_name.length > 0 && !isValidTtiDeviceId(values.tti_name)) + ) { + fieldErrors.tti_name = m.devices_tti_device_id_invalid(); + } + + if (!values.location_id) { + fieldErrors.location_id = m.devices_location_required(); + } + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'updateDevice', + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + console.log('Updating device with values:', { devEui, ...values }); + await api.updateDevice(devEui, { + name: values.name, + group: values.group || null, + location_id: +values.location_id, + tti_name: values.tti_name || null + }); + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateDevice', + message: readApiMessage( + error instanceof ApiServiceError ? error.payload : error, + m.devices_settings_update_rejected() + ), + values, + fieldErrors + }); + } + + return { + action: 'updateDevice', + success: true, + message: m.devices_settings_updated() + }; + }, + updateDeviceOwnerPermission: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const devEui = String(params.dev_eui ?? '').trim(); + + if (!authToken) { + return fail(401, { + action: 'updateDeviceOwnerPermission', + message: m.devices_update_owner_permission_requires_login() + }); + } + + if (!devEui) { + return fail(400, { + action: 'updateDeviceOwnerPermission', + message: m.devices_invalid_device_id() + }); + } + + const formData = await request.formData(); + const values: DeviceOwnerPermissionValues = { + ownerKey: readString(formData.get('ownerKey')), + targetUserEmail: readString(formData.get('targetUserEmail')), + permissionLevel: readString(formData.get('permissionLevel')) + }; + + const fieldErrors: Partial> = {}; + const permissionLevel = Number.parseInt(values.permissionLevel, 10); + + if (!values.targetUserEmail) { + fieldErrors.targetUserEmail = m.devices_owner_email_required(); + } else if (!EMAIL_PATTERN.test(values.targetUserEmail)) { + fieldErrors.targetUserEmail = m.devices_owner_email_invalid(); + } + + if (!Number.isFinite(permissionLevel) || !VALID_PERMISSION_LEVELS.has(permissionLevel)) { + fieldErrors.permissionLevel = m.devices_choose_valid_permission_level(); + } + + const ownerKey = values.ownerKey || values.targetUserEmail; + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'updateDeviceOwnerPermission', + ownerKey, + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await api.updateDevicePermissionLevel(devEui, { + targetUserEmail: values.targetUserEmail, + permissionLevel + }); + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateDeviceOwnerPermission', + ownerKey, + message: readApiMessage( + error instanceof ApiServiceError ? error.payload : error, + m.devices_permission_update_rejected() + ), + values, + fieldErrors + }); + } + + return { + action: 'updateDeviceOwnerPermission', + ownerKey, + success: true, + message: m.devices_permission_updated_for_email({ email: values.targetUserEmail }) + }; + } +}; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte new file mode 100644 index 00000000..c3d3f096 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/+page.svelte @@ -0,0 +1,481 @@ + + + + {m.devices_settings_page_title({ devEui: data.devEui?.toUpperCase() ?? 'UNKNOWN' })} + + +
    + + goto( + resolve('/locations/[location_id]/devices/[dev_eui]', { + location_id, + dev_eui: data.devEui + }) + )} + class="back-button">← {m.devices_back_to_detail()} + +
    { + if (!canSubmitDevice) { + cancel(); + return; + } + + deviceSubmitting = true; + return async ({ update }) => { + try { + await update({ reset: false }); + } finally { + deviceSubmitting = false; + } + }; + }} + > +
    + + {#if data.deviceGroup} + + {/if} +
    + + {#if deviceForm?.message} + + {/if} + +
    +
    + + {#if deviceNameError} +

    {deviceNameError}

    + {/if} +
    + +
    + + + {#if deviceGroupError} +

    {deviceGroupError}

    + {/if} +
    + +
    + + + {#if ttiNameError} +

    {ttiNameError}

    + {/if} +
    + +
    + ({ + label: loc.name, + value: String(loc.location_id) + })) + ]} + bind:value={location_id} + error={locationError || undefined} + /> + + {#if locationError} +

    {locationError}

    + {/if} +
    +
    + +
    + + {m.devices_update_submit()} + +
    +
    +
    + + + {#if !hasSensorCertificates} +

    {m.devices_no_sensor_serial()}

    + {:else} +
    + {#if sensorOneCertificate} +
    +
    +
    + + + {#if sensorOneCertificate.product} + + {/if} +
    + +

    {m.devices_sensor_certificate_note()}

    + + {#if sensorOneCertificate.downloadDisabledReason} +

    {sensorOneCertificate.downloadDisabledReason}

    + {/if} +
    + +
    + + + +
    +
    + {/if} + + {#if sensorOneCertificate && sensorTwoCertificate} + + {/if} + + {#if sensorTwoCertificate} +
    +
    +
    + + + {#if sensorTwoCertificate.product} + + {/if} +
    + +

    {m.devices_sensor_certificate_note()}

    +
    + +
    + + + +
    +
    + {/if} +
    + {/if} +
    + + + {#if permissionRows.length === 0} +

    {m.devices_no_device_owners()}

    + {:else} +
    + {#each permissionRows as row, index (row.key)} + {@const ownerForm = ownerFormFor(row.key)} + {#if row.email && row.email !== ''} + {#if !row.email.includes('@cropwatch.io') || app.session?.email.includes('@cropwatch.io')} +
    +
    { + if (!isOwnerRowValid(row)) { + cancel(); + return; + } + + const key = String(formData.get('ownerKey') ?? row.key); + setOwnerSubmitting(key, true); + + return async ({ update }) => { + try { + await update({ reset: false }); + } finally { + setOwnerSubmitting(key, false); + } + }; + }} + > + + + + +
    + + +

    {row.name} ({row.email})

    +
    + +
    +
    + row.permissionLevel, + (value) => updateOwnerPermissionLevel(row.key, String(value ?? '')) + } + error={ownerForm?.fieldErrors?.permissionLevel || + (!isOwnerRowValid(row) + ? m.devices_choose_valid_permission_level() + : undefined)} + /> + {#if ownerForm?.fieldErrors?.permissionLevel} +

    {ownerForm.fieldErrors.permissionLevel}

    + {/if} +
    + + + {m.devices_update_permission()} + +
    + + {#if ownerForm?.fieldErrors?.targetUserEmail} +

    + {ownerForm.fieldErrors.targetUserEmail} +

    + {/if} + + {#if ownerForm?.message} + + {/if} +
    +
    + {#if index < permissionRows.length - 1} + + {/if} + {/if} + {/if} + {/each} +
    + {/if} +
    +
    diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts new file mode 100644 index 00000000..dac1038f --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/libellus-certificates/[sensor_key]/+server.ts @@ -0,0 +1,111 @@ +import { env } from '$env/dynamic/private'; +import { ApiService } from '$lib/api/api.service'; +import type { DeviceDto } from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import type { RequestHandler } from './$types'; + +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function getSensorSerial(device: DeviceDto, sensorKey: string): string { + const record = device as Record; + return sensorKey === 'sensor2' + ? readString(record.sensor2_serial) + : readString(record.sensor1_serial) || readString(record.sensor_serial); +} + +function getProductName(device: DeviceDto): string { + return readString(device.cw_device_type?.model); +} + +function getLibellusBaseUrl(): string { + const baseUrl = readString(env.PRIVATE_LIBELLUS_BASE_URL); + if (!baseUrl) return ''; + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} + +async function readErrorMessage(response: Response): Promise { + try { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + const payload = (await response.json()) as Record; + return ( + readString(payload.detail) || + readString(payload.message) || + m.devices_libellus_request_failed() + ); + } + + return readString(await response.text()) || m.devices_libellus_request_failed(); + } catch { + return m.devices_libellus_request_failed(); + } +} + +export const GET: RequestHandler = async ({ fetch, locals, params, url }) => { + const authToken = locals.jwtString ?? null; + const devEui = readString(params.dev_eui); + const sensorKey = readString(params.sensor_key); + const apiToken = readString(env.PRIVATE_LIBELLUS_API_TOKEN); + const baseUrl = getLibellusBaseUrl(); + + if (!authToken) { + return new Response(m.devices_sensor_certificate_requires_login(), { status: 401 }); + } + + if (!devEui || (sensorKey !== 'sensor' && sensorKey !== 'sensor2')) { + return new Response(m.devices_invalid_certificate_target(), { status: 400 }); + } + + if (!apiToken) { + return new Response(m.devices_libellus_api_token_missing(), { status: 500 }); + } + + if (!baseUrl) { + return new Response(m.devices_libellus_base_url_missing(), { status: 500 }); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + const device = await api.getDevice(devEui).catch(() => null); + + if (!device) { + return new Response(m.devices_not_found(), { status: 404 }); + } + + const sensorSerial = getSensorSerial(device, sensorKey); + if (!sensorSerial) { + return new Response(m.devices_no_sensor_serial_configured(), { status: 404 }); + } + + const libellusUrl = new URL( + `${encodeURIComponent('SHT43')}/sensors/${encodeURIComponent(sensorSerial)}/certificate`, + baseUrl + ); + libellusUrl.searchParams.set('format', 'application/pdf'); + + const certificateType = readString(url.searchParams.get('type')); + if (certificateType) { + libellusUrl.searchParams.set('type', certificateType); + } + + const response = await fetch(libellusUrl, { + method: 'GET', + headers: { + Authorization: `Token ${apiToken}` + } + }); + + if (!response.ok) { + return new Response(await readErrorMessage(response), { status: response.status }); + } + + return new Response(response.body, { + status: 200, + headers: { + 'cache-control': 'no-store', + 'content-type': response.headers.get('content-type') ?? 'application/pdf', + 'content-disposition': `attachment; filename="SHT43-${sensorSerial}-certificate.pdf"` + } + }); +}; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/settings/settings-style.css b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/settings-style.css new file mode 100644 index 00000000..56a84a79 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/settings/settings-style.css @@ -0,0 +1,177 @@ +.device-settings-page { + display: grid; + gap: 1rem; + padding: 0.75rem; +} + +.device-form { + display: grid; + gap: 0.875rem; +} + +.device-form__header { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.panel-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.panel-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.panel-note { + margin: 0; + font-size: 0.92rem; + color: var(--cw-text-muted); +} + +.field-stack { + display: grid; + gap: 0.35rem; +} + +.field-error { + margin: 0; + color: #b42318; + font-size: 0.85rem; +} + +.form-feedback { + margin: 0; + padding: 0.75rem 0.875rem; + border-radius: 0.75rem; + background: #fee4e2; + color: #912018; + font-size: 0.92rem; +} + +.feedback-success { + background: #d1fadf; + color: #166534; +} + +.permissions-tag { + margin-bottom: 0.75rem; +} + +.permission-list { + display: grid; + gap: 0.75rem; +} + +.permission-row { + display: grid; + gap: 0.75rem; +} + +.permission-row__form { + display: grid; + grid-template-columns: 2fr 1.6fr; + gap: 0.75rem; +} + +.permission-user { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.permission-edit { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: end; + gap: 0.5rem; +} + +.permission-meta { + display: grid; + gap: 0.35rem; + align-self: end; +} + +.permission-user-id { + margin: 0; + font-size: 0.78rem; + color: rgba(15, 23, 42, 0.62); +} + +.permission-feedback { + grid-column: 1 / -1; +} + +.empty-state { + margin: 0; + color: rgba(15, 23, 42, 0.72); +} + +.certificate-list { + display: grid; + gap: 0.75rem; +} + +.certificate-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: end; +} + +.certificate-item__meta { + display: grid; + gap: 0.5rem; +} + +.certificate-download-form { + display: flex; + justify-content: flex-end; +} + +@media (max-width: 900px) { + .panel-grid { + grid-template-columns: 1fr; + } + + .permission-row__form { + grid-template-columns: 1fr; + } + + .permission-edit { + grid-template-columns: 1fr; + } + + .certificate-item { + grid-template-columns: 1fr; + } + + .certificate-download-form { + justify-content: stretch; + } +} + +@media (max-width: 640px) { + .permission-user { + grid-template-columns: 1fr; + } + + .panel-actions { + align-items: stretch; + } + + .panel-actions :global(button) { + width: 100%; + } + + .certificate-download-form :global(button) { + width: 100%; + } +} diff --git a/src/routes/locations/[location_id]/devices/create/+page.server.ts b/src/routes/locations/[location_id]/devices/create/+page.server.ts new file mode 100644 index 00000000..fe5eaf22 --- /dev/null +++ b/src/routes/locations/[location_id]/devices/create/+page.server.ts @@ -0,0 +1,236 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import { isValidTtiDeviceId, normalizeTtiDeviceId } from '$lib/devices/tti-device-id'; +import type { CreateDeviceRequest } from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import { fail, type Actions } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +type DeviceTypeOption = { + label: string; + value: string; +}; + +type CreateDeviceFormValues = { + dev_eui: string; + name: string; + type: string; + group: string; + location_id: string; + lat: string; + long: string; + installed_at: string; + tti_name: string; +}; + +const readString = (value: FormDataEntryValue | null): string => + typeof value === 'string' ? value.trim() : ''; + +const readOptionalNumber = (value: string): number | undefined => { + if (!value) return undefined; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalInteger = (value: string): number | undefined => { + if (!value) return undefined; + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalText = (value: string): string | undefined => (value ? value : undefined); + +const normalizeDevEui = (value: string): string => + value + .replace(/[^0-9a-fA-F]/g, '') + .toUpperCase() + .slice(0, 16); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +function mapDeviceTypeOptions(payload: unknown): DeviceTypeOption[] { + if (!Array.isArray(payload)) return []; + + return payload.flatMap((entry) => { + if (!isRecord(entry)) return []; + + const name = entry.name; + const id = entry.id; + + if (typeof name !== 'string' || (!Number.isFinite(id) && typeof id !== 'string')) { + return []; + } + + return [ + { + label: name, + value: String(id) + } + ]; + }); +} + +function readApiMessage(error: unknown, fallback: string): string { + if (error instanceof ApiServiceError) { + const payload = error.payload as { payload?: { message?: unknown } } | null; + const message = payload?.payload?.message; + + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + + if (Array.isArray(message)) { + const text = message + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .join(', '); + if (text) return text; + } + } + + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + return fallback; +} + +function readFormValues(formData: FormData, locationId: string): CreateDeviceFormValues { + return { + dev_eui: readString(formData.get('dev_eui')), + name: readString(formData.get('name')), + type: readString(formData.get('type')), + group: readString(formData.get('group')), + location_id: readString(formData.get('location_id')) || locationId, + lat: readString(formData.get('lat')), + long: readString(formData.get('long')), + installed_at: readString(formData.get('installed_at')), + tti_name: normalizeTtiDeviceId(readString(formData.get('tti_name'))) + }; +} + +export const load: PageServerLoad = async ({ params, fetch, locals }) => { + const locationId = String(params.location_id ?? '').trim(); + let deviceTypeOptions: DeviceTypeOption[] = []; + + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { error: m.auth_not_authenticated() }); + } + + try { + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + const response = await apiService.getDeviceTypes(); + deviceTypeOptions = mapDeviceTypeOptions(response); + + const locationResponse = await apiService.getLocation(locationId); + const locationName = locationResponse.name ?? ''; + return { + locationId, + locationName, + deviceTypeOptions + }; + } catch { + deviceTypeOptions = []; + } + + // return { + // locationId, + // locationName, + // deviceTypeOptions + // }; +}; + +export const actions: Actions = { + default: async ({ request, locals, params, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { error: m.auth_not_authenticated() }); + } + + const values = readFormValues( + await request.formData(), + String(params.location_id ?? '').trim() + ); + if (!values.dev_eui) { + return fail(400, { error: m.devices_dev_eui_required(), ...values }); + } + + const normalizedDevEui = normalizeDevEui(values.dev_eui); + if (normalizedDevEui.length !== 16) { + return fail(400, { + error: m.devices_dev_eui_invalid(), + ...values + }); + } + + if (!values.name) { + return fail(400, { error: m.devices_device_name_required(), ...values }); + } + + const typeId = readOptionalInteger(values.type); + if (!typeId) { + return fail(400, { error: m.devices_device_type_required(), ...values }); + } + + const locationId = readOptionalInteger(values.location_id); + if (!locationId) { + return fail(400, { error: m.devices_location_id_required(), ...values }); + } + + if (values.tti_name && !isValidTtiDeviceId(values.tti_name)) { + return fail(400, { + error: m.devices_tti_device_id_invalid(), + ...values + }); + } + + const payload: CreateDeviceRequest = { + dev_eui: normalizedDevEui, + type: typeId, + name: values.name, + group: readOptionalText(values.group), + location_id: locationId, + lat: readOptionalNumber(values.lat), + long: readOptionalNumber(values.long), + installed_at: readOptionalText(values.installed_at), + tti_name: readOptionalText(values.tti_name) + }; + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + // The published docs show create on POST /v1/devices/{dev_eui}. + await api.createDevice(normalizedDevEui, payload); + + // The same docs expose follow-up local metadata updates on PATCH /v1/devices/{dev_eui}. + await api.updateDevice(normalizedDevEui, { + name: values.name, + group: readOptionalText(values.group), + location_id: locationId, + tti_name: readOptionalText(values.tti_name) + }); + + return { + success: true, + dev_eui: normalizedDevEui + }; + } catch (error) { + const message = readApiMessage(error, m.devices_create_failed()); + const status = error instanceof ApiServiceError ? error.status : 500; + + return fail(status, { + error: message, + ...values + }); + } + } +}; diff --git a/src/routes/locations/[location_id]/devices/create/+page.svelte b/src/routes/locations/[location_id]/devices/create/+page.svelte new file mode 100644 index 00000000..bae4ad2e --- /dev/null +++ b/src/routes/locations/[location_id]/devices/create/+page.svelte @@ -0,0 +1,342 @@ + + + + {m.devices_create_page_title()} + + +
    + +
    { + submitting = true; + + return async ({ result }) => { + submitting = false; + + if (result.type === 'success' && typeof result.data?.dev_eui === 'string') { + await goto( + resolve('/locations/[location_id]/devices/[dev_eui]', { + location_id: locationId, + dev_eui: result.data.dev_eui + }), + { invalidateAll: true } + ); + return; + } + + await applyAction(result); + + if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + } + }; + }} + > + {#if actionForm?.error} +

    {actionForm.error}

    + {/if} + + + +
    +
    + + + + + + + + + + + + + +
    +
    + +
    +
    +

    {m.devices_deployment_section_title()}

    +
    + +
    + + + + +
    +
    +

    {m.devices_installed_at_label()}

    + + +
    +
    +
    +
    + +
    + goto(resolve('/locations/[location_id]', { location_id: locationId }))} + > + + {m.action_cancel()} + + + {m.devices_create_submit()} +
    +
    +
    +
    + + diff --git a/src/routes/locations/[location_id]/settings/+page.server.ts b/src/routes/locations/[location_id]/settings/+page.server.ts new file mode 100644 index 00000000..9941a6c3 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/+page.server.ts @@ -0,0 +1,433 @@ +import { + ApiService, + ApiServiceError, + type CreateLocationOwnerRequest, + type LocationOwnerDto, + type UpdateLocationOwnerRequest +} from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { fail, type Actions } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type AddPermissionFormValues = { + newUserEmail: string; + userId?: string; + permission_level: number; + applyToAllDevices: boolean; +}; + +type EditPermissionFormValues = { + ownerId: string; + permissionId: string; + userId?: string; + applyToAllDevices: boolean; +}; + +const EMPTY_ADD_VALUES: AddPermissionFormValues = { + newUserEmail: '', + userId: '', + permission_level: 4, + applyToAllDevices: false +}; + +const EMPTY_EDIT_VALUES: EditPermissionFormValues = { + ownerId: '', + permissionId: '', + userId: '', + applyToAllDevices: false +}; + +const readString = (value: FormDataEntryValue | null): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +const readUnknownString = (value: unknown): string => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return ''; +}; + +const readErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof ApiServiceError) { + const payload = error.payload; + if (payload && typeof payload === 'object') { + const record = payload as Record; + const message = record.message; + if (Array.isArray(message)) { + const combined = message + .filter((entry): entry is string => typeof entry === 'string') + .join(', '); + if (combined.length > 0) return combined; + } + if (typeof message === 'string' && message.trim().length > 0) return message.trim(); + } + } + + return fallback; +}; + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const locationId = Number.parseInt(params.location_id ?? '', 10); + + if (!authToken || !Number.isFinite(locationId)) { + return { + locationId: Number.isFinite(locationId) ? locationId : null, + locationName: '', + locationGroup: '', + locationOwners: [] as LocationOwnerDto[] + }; + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + const location = await apiService.getLocation(locationId).catch(() => null); + const currentUserId = readUnknownString(locals.jwt?.sub); + const locationOwnerId = readUnknownString((location as Record | null)?.owner_id); + + if (locationOwnerId !== currentUserId) { + const owner = location?.cw_location_owners?.find( + (owner) => + readUnknownString(owner.user_id) === currentUserId && + readUnknownString(owner.permission_level) === '1' + ); + if (!owner) { + throw fail(403, { + message: m.locations_permission_denied() + }); + } + } + + return { + locationId, + locationName: String(location?.name ?? `Location ${locationId}`), + locationGroup: String(location?.group ?? ''), + locationOwners: (location?.cw_location_owners ?? []) as LocationOwnerDto[] + }; +}; + +export const actions: Actions = { + updateLocationName: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'updateLocationName', + message: m.locations_update_name_requires_login() + }); + } + + const locationId = Number.parseInt(params.location_id ?? '', 10); + if (!Number.isFinite(locationId)) { + return fail(400, { + action: 'updateLocationName', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const newName = readString(formData.get('locationName')); + const newGroup = readString(formData.get('group')); + + if (!newName) { + return fail(400, { + action: 'updateLocationName', + message: m.locations_name_cannot_be_empty() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocation(locationId, { name: newName, group: newGroup }); + + return { + action: 'updateLocationName', + success: true, + message: m.locations_name_updated() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateLocationName', + message: readErrorMessage(error, m.locations_update_name_failed()) + }); + } + }, + addPermission: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'addPermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_ADD_VALUES + }); + } + + const locationId = Number.parseInt(params.location_id ?? '', 10); + if (!Number.isFinite(locationId)) { + return fail(400, { + action: 'addPermission', + message: m.locations_invalid_location_id(), + values: EMPTY_ADD_VALUES + }); + } + + const formData = await request.formData(); + const values: AddPermissionFormValues = { + newUserEmail: readString(formData.get('newUserEmail')), + permission_level: Number.parseInt(readString(formData.get('permission_level')), 10) || 4, + applyToAllDevices: formData.get('applyToAllDevices') === 'true' + }; + + const fieldErrors: Partial> = {}; + if (!values.newUserEmail) { + fieldErrors.newUserEmail = m.validation_user_email_required(); + } else if (!EMAIL_PATTERN.test(values.newUserEmail)) { + fieldErrors.newUserEmail = m.validation_valid_email_required(); + } + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'addPermission', + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.createLocationPermission( + locationId, + values.newUserEmail, + values.permission_level ?? 4, + values.applyToAllDevices + ); + + return { + action: 'addPermission', + success: true, + message: m.locations_permission_created(), + values: EMPTY_ADD_VALUES + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'addPermission', + message: readErrorMessage(error, m.locations_permission_create_failed()), + values, + fieldErrors + }); + } + }, + editPermission: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'editPermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_EDIT_VALUES + }); + } + + const locationId = Number.parseInt(params.location_id ?? '', 10); + if (!Number.isFinite(locationId)) { + return fail(400, { + action: 'editPermission', + message: m.locations_invalid_location_id(), + values: EMPTY_EDIT_VALUES + }); + } + + const formData = await request.formData(); + const values: EditPermissionFormValues = { + ownerId: readString(formData.get('ownerId')), + permissionId: readString(formData.get('permissionId')), + userId: readString(formData.get('userId')), + applyToAllDevices: formData.get('applyToAllDevices') === 'true' + }; + + const fieldErrors: Partial> = {}; + const ownerId = Number.parseInt(values.ownerId, 10); + const permissionId = Number.parseInt(values.permissionId, 10); + + if (!Number.isFinite(ownerId)) { + fieldErrors.ownerId = m.locations_select_permission_to_edit(); + } + + if (!values.userId) { + fieldErrors.userId = m.locations_user_id_required(); + } + + const adminUserId = typeof locals.jwt?.sub === 'string' ? locals.jwt.sub.trim() : ''; + if (!adminUserId) { + return fail(400, { + action: 'editPermission', + message: m.locations_missing_admin_user_id(), + values, + fieldErrors + }); + } + + if (Object.keys(fieldErrors).length > 0) { + return fail(400, { + action: 'editPermission', + message: m.validation_correct_highlighted_fields(), + values, + fieldErrors + }); + } + + const updateLocationOwnerPayload: UpdateLocationOwnerRequest = { + admin_user_id: adminUserId, + location_id: locationId, + user_id: values.userId, + owner_id: ownerId + }; + if (Number.isFinite(permissionId)) { + updateLocationOwnerPayload.id = permissionId; + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocationPermission( + locationId, + updateLocationOwnerPayload, + values.applyToAllDevices + ); + + return { + action: 'editPermission', + success: true, + message: m.locations_permission_updated(), + values: EMPTY_EDIT_VALUES + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'editPermission', + message: readErrorMessage(error, m.locations_permission_update_failed()), + values, + fieldErrors + }); + } + }, + updateUserPermissionLevel: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'updateUserPermissionLevel', + message: m.locations_manage_permissions_requires_login() + }); + } + + const locationId = Number.parseInt(params.location_id ?? '', 10); + if (!Number.isFinite(locationId)) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const permissionLevel = readString(formData.get('permission_level')); + const email = readString(formData.get('email')); + + if (!email) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.validation_email_required() + }); + } + + if (!permissionLevel) { + return fail(400, { + action: 'updateUserPermissionLevel', + message: m.locations_permission_level_required() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + await apiService.updateLocationPermissionLevel(locationId, email, +permissionLevel); + + return { + action: 'updateUserPermissionLevel', + success: true, + message: m.locations_user_permission_level_updated() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'updateUserPermissionLevel', + message: readErrorMessage(error, m.locations_user_permission_level_update_failed()) + }); + } + }, + removePermission: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { + action: 'removePermission', + message: m.locations_manage_permissions_requires_login(), + values: EMPTY_ADD_VALUES + }); + } + + const locationId = readUnknownString(params.location_id); + if (!locationId) { + return fail(400, { + action: 'removePermission', + message: m.locations_invalid_location_id() + }); + } + + const formData = await request.formData(); + const permissionIdRaw = readString(formData.get('permission_id')); + const permissionId = Number.parseInt(permissionIdRaw, 10); + if (!Number.isFinite(permissionId)) { + return fail(400, { + action: 'removePermission', + message: m.locations_permission_id_required() + }); + } + + const apiService = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + const response = await apiService.deleteLocationPermission(locationId, permissionId); + console.log('Delete permission response:', response); + + return { + action: 'removePermission', + success: true, + message: m.locations_permission_removed() + }; + } catch (error) { + return fail(error instanceof ApiServiceError ? error.status : 502, { + action: 'removePermission', + message: readErrorMessage(error, m.locations_permission_remove_failed()) + }); + } + } +}; diff --git a/src/routes/locations/[location_id]/settings/+page.svelte b/src/routes/locations/[location_id]/settings/+page.svelte new file mode 100644 index 00000000..c8958842 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/+page.svelte @@ -0,0 +1,24 @@ + + + + {m.locations_settings_page_title({ name: data.locationName })} + + + goto(`/locations/${data.locationId}`)} class="back-button my-2"> + {m.locations_back_to_location()} + +
    + + + +
    diff --git a/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte b/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte new file mode 100644 index 00000000..f8c4f695 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/DeletePermissionDialog.svelte @@ -0,0 +1,91 @@ + + + +

    {m.locations_confirm_delete_permission_body({ email: selectedRow?.email ?? '' })}

    + {#snippet actions()} + (openDeletePermissionDialog = false)}>{m.action_cancel()} + handlePermissionDelete(selectedRow)}>{m.action_delete()} + {/snippet} +
    diff --git a/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte new file mode 100644 index 00000000..224f6128 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte @@ -0,0 +1,131 @@ + + +{#key permissionsKey} + + {#snippet cell(row: Permission, col: CwColumnDef, defaultValue: string)} + {#if col.key === 'permission_level'} + {#if editingPermissionId === row.id} + + {:else} + {getPermissionLevelLabel(row.permission_level, m.permission_level_viewer())} + {/if} + {:else} + {defaultValue} + {/if} + {/snippet} + {#snippet rowActions(row: Permission)} + + {#if editingPermissionId === row.id} + { + savePermissionLevelUpdate(row); + }}>{m.action_save_changes()} + { + selectedRow = row; + openDeletePermissionDialog = true; + }}>{m.action_delete()} + { + selectedRow = null; + editingPermissionId = null; + }}>{m.action_cancel()} + {:else} + { + editingPermissionId = row.id; + }} + > + + + {/if} + + {/snippet} + +{/key} + + diff --git a/src/routes/locations/[location_id]/settings/LocationPermissions.svelte b/src/routes/locations/[location_id]/settings/LocationPermissions.svelte new file mode 100644 index 00000000..20bd3280 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/LocationPermissions.svelte @@ -0,0 +1,131 @@ + + +
    + +
    { + submitting = true; + return async ({ result }) => { + try { + await applyAction(result); + const message = getResultMessage(result); + if (message) { + toast.add({ + message, + tone: result.type === 'success' ? 'success' : 'danger' + }); + } + if (result.type === 'success') { + newUserEmail = ''; + await invalidateAll(); + } + } finally { + submitting = false; + } + }; + }} + class="permissions-form" + > + + ({ + value: String(p.permission_level), + label: p.permission_name + }))} + bind:value={permission_level} + /> + + (applyToAllDevices = checked)} + /> + + +
    + + + {m.locations_add_permission()} + +
    + +
    +
    + + diff --git a/src/routes/locations/[location_id]/settings/LocationUpdate.svelte b/src/routes/locations/[location_id]/settings/LocationUpdate.svelte new file mode 100644 index 00000000..32da1670 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/LocationUpdate.svelte @@ -0,0 +1,103 @@ + + +
    + +
    { + if (!canSubmitLocation) { + cancel(); + return; + } + + submitting = true; + return async ({ result }) => { + try { + await applyAction(result); + + const message = getResultMessage(result); + if (message) { + toast.add({ + message, + tone: result.type === 'success' ? 'success' : 'danger' + }); + } + + if (result.type === 'success') { + await invalidateAll(); + } + } finally { + submitting = false; + } + }; + }} + > + + + +
    + + + {m.action_save_changes()} + +
    + +
    +
    diff --git a/src/routes/locations/[location_id]/settings/location-permission-rows.spec.ts b/src/routes/locations/[location_id]/settings/location-permission-rows.spec.ts new file mode 100644 index 00000000..fd39ab10 --- /dev/null +++ b/src/routes/locations/[location_id]/settings/location-permission-rows.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { LocationOwnerDto } from '$lib/api/api.dtos'; +import { mapLocationOwnersToPermissionRows } from './location-permission-rows'; + +describe('mapLocationOwnersToPermissionRows', () => { + it('maps location owners into editable permission rows', () => { + const rows = mapLocationOwnersToPermissionRows([ + { + id: 14, + user_id: 'user-1', + location_id: 9, + owner_id: 3, + admin_user_id: 'admin-1', + permission_level: 2, + profiles: { + email: 'owner@example.com', + full_name: 'Owner Name' + } + } as unknown as LocationOwnerDto + ]); + + expect(rows).toEqual([ + { + id: 14, + email: 'owner@example.com', + name: 'Owner Name', + permission_level: '2' + } + ]); + }); + + it('falls back to nested permission records and missing profile data', () => { + const rows = mapLocationOwnersToPermissionRows([ + { + id: 21, + user_id: 'user-2', + location_id: 9, + owner_id: 3, + admin_user_id: 'admin-1', + permission_level: { + value: '3' + }, + email: 'direct@example.com', + name: 'Direct Name' + } as unknown as LocationOwnerDto + ]); + + expect(rows).toEqual([ + { + id: 21, + email: 'direct@example.com', + name: 'Direct Name', + permission_level: '3' + } + ]); + }); + + it('returns an empty list when there are no location owners', () => { + expect(mapLocationOwnersToPermissionRows(undefined)).toEqual([]); + }); +}); diff --git a/src/routes/locations/[location_id]/settings/location-permission-rows.ts b/src/routes/locations/[location_id]/settings/location-permission-rows.ts new file mode 100644 index 00000000..6a702d5d --- /dev/null +++ b/src/routes/locations/[location_id]/settings/location-permission-rows.ts @@ -0,0 +1,58 @@ +import type { LocationOwnerDto } from '$lib/api/api.dtos'; + +export interface PermissionRow { + id: number; + email: string; + name: string; + permission_level: string; +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readPermissionLevel(value: unknown): string { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + const direct = readString(value); + if (direct) { + return direct; + } + + if (!value || typeof value !== 'object') { + return '4'; + } + + const record = value as Record; + return ( + readString(record.permission_level) || + readString(record.value) || + readString(record.level) || + '4' + ); +} + +export function mapLocationOwnersToPermissionRows( + locationOwners: LocationOwnerDto[] | undefined +): PermissionRow[] { + return (locationOwners ?? []).map((owner) => { + const record = owner as Record; + const profiles = + record.profiles && typeof record.profiles === 'object' + ? (record.profiles as Record) + : undefined; + + return { + id: owner.id, + email: readString(record.email) || readString(record.user_email) || readString(profiles?.email), + name: + readString(record.name) || + readString(record.full_name) || + readString(profiles?.full_name) || + readString(profiles?.display_name), + permission_level: readPermissionLevel(record.permission_level) + }; + }); +} diff --git a/src/routes/locations/create/+page.server.ts b/src/routes/locations/create/+page.server.ts new file mode 100644 index 00000000..5f8f39ad --- /dev/null +++ b/src/routes/locations/create/+page.server.ts @@ -0,0 +1,94 @@ +import { ApiService } from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { fail } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readLocationId(payload: unknown): number | null { + if (!isRecord(payload)) { + return null; + } + + const directLocationId = payload.location_id; + if (typeof directLocationId === 'number' && Number.isFinite(directLocationId)) { + return directLocationId; + } + + if (typeof directLocationId === 'string') { + const parsed = Number.parseInt(directLocationId, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + for (const nestedKey of ['data', 'result']) { + const nestedLocationId = readLocationId(payload[nestedKey]); + if (nestedLocationId !== null) { + return nestedLocationId; + } + } + + return null; +} + +export const actions: Actions = { + default: async ({ request, locals, fetch }) => { + const authToken = locals.jwtString ?? null; + if (!authToken) { + return fail(401, { error: m.auth_not_authenticated() }); + } + + const formData = await request.formData(); + const name = formData.get('name')?.toString().trim() ?? ''; + + if (!name) { + return fail(400, { error: m.validation_name_required(), name }); + } + + const description = formData.get('description')?.toString().trim() || null; + const group = formData.get('group')?.toString().trim() || null; + const latRaw = formData.get('lat')?.toString().trim(); + const longRaw = formData.get('long')?.toString().trim(); + const lat = latRaw ? Number(latRaw) : null; + const long = longRaw ? Number(longRaw) : null; + const formValues = { + name, + description: description ?? '', + group: group ?? '', + lat: latRaw ?? '', + long: longRaw ?? '' + }; + + if (latRaw && isNaN(lat!)) { + return fail(400, { error: m.locations_latitude_must_be_number(), ...formValues }); + } + if (longRaw && isNaN(long!)) { + return fail(400, { error: m.locations_longitude_must_be_number(), ...formValues }); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + + try { + const createdLocation = await api.createLocation({ name, description, group, lat, long }); + const location_id = readLocationId(createdLocation); + + if (!location_id) { + return fail(502, { + error: m.locations_missing_location_id(), + ...formValues + }); + } + + return { + success: true, + location_id + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : m.locations_create_failed(); + return fail(500, { error: message, ...formValues }); + } + } +}; diff --git a/src/routes/locations/create/+page.svelte b/src/routes/locations/create/+page.svelte new file mode 100644 index 00000000..f734bf60 --- /dev/null +++ b/src/routes/locations/create/+page.svelte @@ -0,0 +1,150 @@ + + +
    + +
    { + submitting = true; + return async ({ result }) => { + submitting = false; + + if (result.type === 'success') { + const locationId = result.data?.location_id; + if (typeof locationId === 'number' && Number.isFinite(locationId)) { + await goto( + resolve('/locations/[location_id]', { + location_id: String(locationId) + }), + { invalidateAll: true } + ); + return; + } + } + + await applyAction(result); + + if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + } + }; + }} + > +
    + {#if form?.error} +

    {form.error}

    + {/if} + + + + + + + +
    + + +
    +
    + +
    + goto('/locations')}> + + {m.action_cancel()} + + + {m.locations_create_submit()} + +
    +
    +
    +
    + + diff --git a/src/routes/manifest.webmanifest/+server.ts b/src/routes/manifest.webmanifest/+server.ts new file mode 100644 index 00000000..7d6fd08f --- /dev/null +++ b/src/routes/manifest.webmanifest/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from './$types'; + +import { buildManifest } from '$lib/pwa/manifest'; +import { getLocale } from '$lib/paraglide/runtime'; + +const MANIFEST_ROUTE_SUFFIX = '/manifest.webmanifest'; + +function getBasePath(pathname: string): string { + return pathname.endsWith(MANIFEST_ROUTE_SUFFIX) + ? pathname.slice(0, -MANIFEST_ROUTE_SUFFIX.length) + : ''; +} + +export const GET: RequestHandler = ({ url }) => { + const manifest = buildManifest(getBasePath(url.pathname), getLocale()); + + return new Response(JSON.stringify(manifest, null, 2), { + headers: { + 'cache-control': 'public, max-age=0, must-revalidate', + 'content-type': 'application/manifest+json; charset=utf-8' + } + }); +}; diff --git a/src/routes/offline/+page.svelte b/src/routes/offline/+page.svelte new file mode 100644 index 00000000..4605234d --- /dev/null +++ b/src/routes/offline/+page.svelte @@ -0,0 +1,81 @@ + + + + {m.offline_page_title()} + + + +
    +
    +

    {m.offline_eyebrow()}

    +

    {m.offline_heading()}

    +

    {m.offline_body()}

    + +
    +
    + + diff --git a/src/routes/offline/+page.ts b/src/routes/offline/+page.ts new file mode 100644 index 00000000..ae88a275 --- /dev/null +++ b/src/routes/offline/+page.ts @@ -0,0 +1,2 @@ +export const prerender = false; +export const ssr = false; diff --git a/src/routes/page.svelte.test.ts b/src/routes/page.svelte.test.ts deleted file mode 100644 index a1106627..00000000 --- a/src/routes/page.svelte.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import '@testing-library/jest-dom/vitest'; -import { render, screen } from '@testing-library/svelte'; -import Page from './+page.svelte'; - -describe('/+page.svelte', () => { - test('should render h1', () => { - render(Page); - expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); - }); -}); diff --git a/src/routes/reports/+page.server.ts b/src/routes/reports/+page.server.ts new file mode 100644 index 00000000..864f5757 --- /dev/null +++ b/src/routes/reports/+page.server.ts @@ -0,0 +1,56 @@ +import { ApiService } from '$lib/api/api.service'; +import { formatDateTime } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; +import type { ReportDeviceRelations, ReportRow } from './report-row'; +import type { PageServerLoad } from './$types'; + +type ReportApiRow = ReportDeviceRelations & { + created_at: string; +}; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + const session = locals.jwt ?? null; + const authToken = locals.jwtString ?? null; + + if (!authToken) { + return { + session, + devices: [], + totalDeviceCount: 0, + triggeredRulesCount: 0 + }; + } + + const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken + }); + + const reports = await apiServiceInstance.getReports().catch(() => []); + + const reportsResult: ReportRow[] = reports.map((report) => { + const reportWithRelations = report as typeof report & ReportApiRow; + const deviceOwners = reportWithRelations.cw_devices?.cw_device_owners ?? []; + + return { + ...reportWithRelations, + permission_level: + deviceOwners.find( + (owner) => + owner.user_id === session?.sub && + owner.permission_level != null && + owner.permission_level <= 3 + )?.permission_level ?? null, + created_at: formatDateTime(reportWithRelations.created_at), + location_name: + reportWithRelations.cw_devices?.cw_locations?.name ?? m.reports_unknown_location(), + device_name: reportWithRelations.cw_devices?.name ?? m.reports_unknown_device() + }; + }); + + return { + session, + authToken, + reports: reportsResult + }; +}; diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte new file mode 100644 index 00000000..96e3b96b --- /dev/null +++ b/src/routes/reports/+page.svelte @@ -0,0 +1,180 @@ + + + + {m.reports_page_title()} + + + + goto(resolve('/'))}> + ← {m.action_back_to_dashboard()} + + + + {#key tableKey} + + {#snippet cell(row: ReportRow, col: CwColumnDef, defaultValue: string)} + {#if col.key === 'device_name'} + {#if row.cw_devices?.cw_locations?.location_id} + + {row.cw_devices?.name ?? m.reports_unknown_device()} + + {:else} + {row.cw_devices?.name ?? m.reports_unknown_device()} + {/if} + {:else} + {defaultValue} + {/if} + {/snippet} + + {#snippet rowActions(row: ReportRow)} +
    + {#if row.permission_level != null && row.permission_level <= 3} + + {/if} + {#if row.permission_level != null && row.permission_level <= 2} + { + goto(resolve('/reports/[report_id]/edit', { report_id: row.report_id })); + }} + > + + + {/if} + {#if row.user_id === app.session?.sub} + + {/if} +
    + {/snippet} + + {#snippet toolbarActions()} + { + goto(resolve('/reports/new/edit')); + }} + > + + + {/snippet} +
    + {/key} +
    +
    + + diff --git a/src/routes/reports/DeleteReportDialog.svelte b/src/routes/reports/DeleteReportDialog.svelte new file mode 100644 index 00000000..65c89aa1 --- /dev/null +++ b/src/routes/reports/DeleteReportDialog.svelte @@ -0,0 +1,133 @@ + + + (open = true)}> + + + + +

    {m.reports_confirm_delete_body({ name: reportName })}

    +
    + (open = false)}> + {m.action_cancel()} + + void deleteReport(reportId)}>{m.action_delete()} +
    +
    diff --git a/src/routes/reports/ReportHistoryDialog.svelte b/src/routes/reports/ReportHistoryDialog.svelte new file mode 100644 index 00000000..1b3bde8e --- /dev/null +++ b/src/routes/reports/ReportHistoryDialog.svelte @@ -0,0 +1,164 @@ + + + (open = true)}> + + + + (open = false)} class="w-full"> + + + {m.reports_problems_only_report()} + + + + {#if open} + + {#snippet rowActions(row: ReportHistoryRow)} + { + handleDownload(dev_eui, report_id, row.name); + }} + > + {m.action_download()} + + {/snippet} + + {/if} + + {#snippet actions()} + (open = false)}>{m.action_close()} + {/snippet} + diff --git a/src/routes/reports/[report_id]/edit/+page.server.ts b/src/routes/reports/[report_id]/edit/+page.server.ts new file mode 100644 index 00000000..c332cc55 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/+page.server.ts @@ -0,0 +1,541 @@ +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { + CreateReportAlertPointRequest, + CreateReportDataProcessingScheduleRequest, + CreateReportRecipientRequest, + CreateReportRequest, + CreateReportUserScheduleRequest +} from '$lib/api/api.dtos'; +import { m } from '$lib/paraglide/messages.js'; +import { error, fail, type Actions } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +type CurrentUser = { + id: string; + email: string; + name: string; +}; + +const REPORT_CREATED_SUCCESS_MESSAGE = 'Report created successfully.'; +const REPORT_UPDATED_SUCCESS_MESSAGE = 'Report updated successfully.'; +const REPORT_UPDATE_FAILED_MESSAGE = 'Failed to update report.'; + +const readString = (value: unknown): string => + typeof value === 'string' + ? value.trim() + : typeof value === 'number' && Number.isFinite(value) + ? String(value) + : ''; + +const readOptionalString = (value: unknown): string | undefined => { + const normalized = readString(value); + return normalized.length > 0 ? normalized : undefined; +}; + +const readOptionalInteger = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + + const normalized = readString(value); + if (!normalized) return undefined; + + const parsed = Number.parseInt(normalized, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + const normalized = readString(value); + if (!normalized) return undefined; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const readOptionalBoolean = (value: unknown, fallback?: boolean): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + + return fallback; +}; + +const normalizeDevEui = (value: string): string => + value + .replace(/[^0-9a-fA-F]/g, '') + .toUpperCase() + .slice(0, 16); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function readApiMessage(sourceError: unknown, fallback: string): string { + if (sourceError instanceof ApiServiceError) { + const payload = sourceError.payload as { payload?: { message?: unknown } } | null; + const message = payload?.payload?.message; + + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + + if (Array.isArray(message)) { + const text = message + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .join(', '); + + if (text) return text; + } + } + + if (sourceError instanceof Error && sourceError.message.trim()) { + return sourceError.message.trim(); + } + + return fallback; +} + +function readReportId(payload: unknown): string | null { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return null; + } + + const record = payload as Record; + const reportId = record.report_id; + if (typeof reportId === 'string' && reportId.trim()) { + return reportId.trim(); + } + + for (const nestedKey of ['data', 'result']) { + const nestedReportId = readReportId(record[nestedKey]); + if (nestedReportId) { + return nestedReportId; + } + } + + return null; +} + +function readApiStatus(sourceError: unknown, fallback = 500): number { + if ( + sourceError instanceof ApiServiceError && + Number.isInteger(sourceError.status) && + sourceError.status >= 400 && + sourceError.status < 600 + ) { + return sourceError.status; + } + + return fallback; +} + +function parsePayload(formData: FormData): { raw: string; value: Record } | null { + const raw = readString(formData.get('payload')); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return null; + } + + return { raw, value: parsed }; + } catch { + return null; + } +} + +function sanitizeScheduleEntries( + value: unknown, + defaults: { + dev_eui: string; + report_id?: string; + user_id?: string; + } +): CreateReportUserScheduleRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const schedules = value + .filter(isRecord) + .map((entry) => { + const devEuiSource = readOptionalString(entry.dev_eui); + const devEui = normalizeDevEui(devEuiSource ?? defaults.dev_eui); + if (!devEui) { + return null; + } + + const schedule: CreateReportUserScheduleRequest = { + dev_eui: devEui, + end_of_month: readOptionalBoolean(entry.end_of_month, false) ?? false, + end_of_week: readOptionalBoolean(entry.end_of_week, false) ?? false, + is_active: readOptionalBoolean(entry.is_active, true) ?? true + }; + + const createdAt = readOptionalString(entry.created_at); + const id = readOptionalInteger(entry.id); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const reportUserScheduleId = readOptionalInteger(entry.report_user_schedule_id); + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + + if (createdAt) schedule.created_at = createdAt; + if (id !== undefined) schedule.id = id; + if (reportId) schedule.report_id = reportId; + if (reportUserScheduleId !== undefined) { + schedule.report_user_schedule_id = reportUserScheduleId; + } + if (userId) schedule.user_id = userId; + + return schedule; + }) + .filter((entry): entry is CreateReportUserScheduleRequest => entry !== null); + + return schedules.length > 0 ? schedules : undefined; +} + +function sanitizeAlertEntries( + value: unknown, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportAlertPointRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const alerts = value + .filter(isRecord) + .map((entry) => { + const name = readOptionalString(entry.name); + const dataPointKey = readOptionalString(entry.data_point_key); + + if (!name || !dataPointKey) { + return null; + } + + const alert: CreateReportAlertPointRequest = { + name, + data_point_key: dataPointKey + }; + + const createdAt = readOptionalString(entry.created_at); + const hexColor = readOptionalString(entry.hex_color); + const id = readOptionalInteger(entry.id); + const max = readOptionalNumber(entry.max); + const min = readOptionalNumber(entry.min); + const operator = readOptionalString(entry.operator); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + const valueNumber = readOptionalNumber(entry.value); + + if (createdAt) alert.created_at = createdAt; + if (hexColor) alert.hex_color = hexColor; + if (id !== undefined) alert.id = id; + if (max !== undefined) alert.max = max; + if (min !== undefined) alert.min = min; + if (operator) alert.operator = operator; + if (reportId) alert.report_id = reportId; + if (userId) alert.user_id = userId; + if (valueNumber !== undefined) alert.value = valueNumber; + + return alert; + }) + .filter((entry): entry is CreateReportAlertPointRequest => entry !== null); + + return alerts.length > 0 ? alerts : undefined; +} + +function sanitizeRecipientEntries( + value: unknown, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportRecipientRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + const recipients = value + .filter(isRecord) + .map((entry) => { + const communicationMethod = readOptionalInteger(entry.communication_method); + const email = readOptionalString(entry.email); + const name = readOptionalString(entry.name); + const userId = readOptionalString(entry.user_id) ?? defaults.user_id; + + if (!communicationMethod || (!email && !userId)) { + return null; + } + + const recipient: CreateReportRecipientRequest = { + communication_method: communicationMethod + }; + + const createdAt = readOptionalString(entry.created_at); + const id = readOptionalInteger(entry.id); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + + if (createdAt) recipient.created_at = createdAt; + if (email) recipient.email = email; + if (id !== undefined) recipient.id = id; + if (name) recipient.name = name; + if (reportId) recipient.report_id = reportId; + if (userId) recipient.user_id = userId; + + return recipient; + }) + .filter((entry): entry is CreateReportRecipientRequest => entry !== null); + + return recipients.length > 0 ? recipients : undefined; +} + +function sanitizeDataProcessingScheduleEntries( + value: unknown, + defaults: { report_id?: string } +): CreateReportDataProcessingScheduleRequest[] | undefined { + if (!Array.isArray(value)) return undefined; + + // Validate HH:mm format + const isValidTime = (t: unknown): t is string => + typeof t === 'string' && /^\d{2}:\d{2}$/.test(t); + + const entries = value + .filter(isRecord) + .map((entry) => { + const dayOfWeek = readOptionalInteger(entry.day_of_week); + const startTime = isValidTime(entry.start_time) ? entry.start_time : undefined; + const endTime = isValidTime(entry.end_time) ? entry.end_time : undefined; + + if (dayOfWeek === undefined || !startTime || !endTime) { + return null; + } + + const schedule: CreateReportDataProcessingScheduleRequest = { + day_of_week: dayOfWeek, + start_time: startTime, + end_time: endTime + }; + + const crossesMidnight = readOptionalBoolean(entry.crosses_midnight); + const id = readOptionalString(entry.id); + const isEnabled = readOptionalBoolean(entry.is_enabled); + const reportId = readOptionalString(entry.report_id) ?? defaults.report_id; + const ruleType = readOptionalString(entry.rule_type); + const timezone = readOptionalString(entry.timezone); + + if (crossesMidnight !== undefined) schedule.crosses_midnight = crossesMidnight; + if (id) schedule.id = id; + if (isEnabled !== undefined) schedule.is_enabled = isEnabled; + if (reportId) schedule.report_id = reportId; + if (ruleType) schedule.rule_type = ruleType; + if (timezone) schedule.timezone = timezone; + + return schedule; + }) + .filter((e): e is CreateReportDataProcessingScheduleRequest => e !== null); + + return entries.length > 0 ? entries : undefined; +} + +function sanitizeReportPayload( + payload: Record, + defaults: { + report_id?: string; + user_id?: string; + } +): CreateReportRequest { + const name = readString(payload.name); + const devEui = normalizeDevEui(readString(payload.dev_eui)); + const reportId = readOptionalString(payload.report_id) ?? defaults.report_id; + const userId = readOptionalString(payload.user_id) ?? defaults.user_id; + + const sanitized: CreateReportRequest = { + name, + dev_eui: devEui + }; + + const createdAt = readOptionalString(payload.created_at); + const id = readOptionalInteger(payload.id); + + if (createdAt) sanitized.created_at = createdAt; + if (id !== undefined) sanitized.id = id; + if (reportId) sanitized.report_id = reportId; + if (userId) sanitized.user_id = userId; + + const reportUserSchedule = sanitizeScheduleEntries(payload.report_user_schedule, { + dev_eui: devEui, + report_id: reportId, + user_id: userId + }); + const reportAlertPoints = sanitizeAlertEntries(payload.report_alert_points, { + report_id: reportId, + user_id: userId + }); + const reportRecipients = sanitizeRecipientEntries(payload.report_recipients, { + report_id: reportId, + user_id: userId + }); + + const reportDataProcessingSchedules = sanitizeDataProcessingScheduleEntries( + payload.report_data_processing_schedules, + { report_id: reportId } + ); + + if (reportUserSchedule) sanitized.report_user_schedule = reportUserSchedule; + if (reportAlertPoints) sanitized.report_alert_points = reportAlertPoints; + if (reportRecipients) sanitized.report_recipients = reportRecipients; + if (reportDataProcessingSchedules) + sanitized.report_data_processing_schedules = reportDataProcessingSchedules; + + return sanitized; +} + +function readCurrentUser(locals: App.Locals): CurrentUser | null { + const jwt = locals.jwt; + const id = jwt?.sub?.trim(); + if (!jwt || !id) { + return null; + } + + const email = jwt.user_metadata?.email?.trim() || jwt.email?.trim() || ''; + const name = + jwt.user_metadata?.full_name?.trim() || jwt.user_metadata?.name?.trim() || email || id; + + return { id, email, name }; +} + +export const load: PageServerLoad = async ({ locals, fetch, params, url }) => { + const authToken = locals.jwtString ?? null; + const reportId = readString(params.report_id); + const isCreate = reportId === 'new'; + const currentUser = readCurrentUser(locals); + + if (!authToken) { + error(401, m.error_unauthorized_title()); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + + if (isCreate) { + const devEui = url.searchParams.get('dev_eui')?.trim() || null; + const devices = await api.getAllDevices().catch(() => []); + return { authToken, currentUser, devices, devEui, report: null }; + } + + if (!reportId) { + error(404, m.error_not_found_title()); + } + + try { + const [report, devices] = await Promise.all([ + api.getReport(reportId), + api.getAllDevices().catch(() => []) + ]); + + return { + authToken, + currentUser, + devices, + devEui: null, + report + }; + } catch (sourceError) { + const status = readApiStatus(sourceError); + const fallback = status === 404 ? m.error_not_found_title() : REPORT_UPDATE_FAILED_MESSAGE; + error(status, readApiMessage(sourceError, fallback)); + } +}; + +export const actions: Actions = { + default: async ({ request, locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const reportId = readString(params.report_id); + const isCreate = reportId === 'new'; + const currentUser = readCurrentUser(locals); + + if (!authToken) { + return fail(401, { error: m.auth_not_authenticated() }); + } + + if (!isCreate && !reportId) { + return fail(404, { error: m.error_not_found_title() }); + } + + const parsedPayload = parsePayload(await request.formData()); + if (!parsedPayload) { + return fail(400, { + error: m.reports_create_payload_unreadable() + }); + } + + const sanitizedPayload = sanitizeReportPayload(parsedPayload.value, { + report_id: isCreate ? undefined : reportId, + user_id: currentUser?.id + }); + + if (!sanitizedPayload.name) { + return fail(400, { + error: m.validation_name_required(), + payload: parsedPayload.raw + }); + } + + if (!sanitizedPayload.dev_eui) { + return fail(400, { + error: m.devices_dev_eui_required(), + payload: parsedPayload.raw + }); + } + + if (sanitizedPayload.dev_eui.length !== 16) { + return fail(400, { + error: m.devices_dev_eui_invalid(), + payload: parsedPayload.raw + }); + } + + const api = new ApiService({ + fetchFn: fetch, + authToken + }); + + try { + if (isCreate) { + const createdReport = await api.createReport(sanitizedPayload); + + return { + success: true, + message: REPORT_CREATED_SUCCESS_MESSAGE, + report_id: readReportId(createdReport) ?? undefined + }; + } + + await api.updateReport(reportId, sanitizedPayload); + + return { + success: true, + message: REPORT_UPDATED_SUCCESS_MESSAGE, + report_id: reportId + }; + } catch (sourceError) { + const message = readApiMessage( + sourceError, + isCreate ? m.reports_create_failed() : REPORT_UPDATE_FAILED_MESSAGE + ); + const status = readApiStatus(sourceError); + + return fail(status, { + error: message, + payload: parsedPayload.raw + }); + } + } +}; diff --git a/src/routes/reports/[report_id]/edit/+page.svelte b/src/routes/reports/[report_id]/edit/+page.svelte new file mode 100644 index 00000000..aba63320 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/+page.svelte @@ -0,0 +1,872 @@ + + + + {isEditing ? m.reports_edit_page_title() : m.reports_create_page_title()} | CropWatch + + + +
    + goto(resolve('/reports'))}> + {m.action_back()} + + +
    { + submitAttempted = true; + + if (!canSubmit) { + cancel(); + toast.add({ + tone: 'danger', + message: validationIssues[0] ?? m.reports_create_invalid_toast() + }); + return; + } + + submitting = true; + + return async ({ result }) => { + submitting = false; + + if (result.type === 'success') { + toast.add({ + tone: 'success', + message: + typeof result.data?.message === 'string' + ? result.data.message + : isEditing + ? REPORT_UPDATED_SUCCESS_MESSAGE + : REPORT_CREATED_SUCCESS_MESSAGE + }); + await goto(resolve('/reports'), { invalidateAll: true }); + return; + } + + await applyAction(result); + + if (result.type === 'failure' && typeof result.data?.error === 'string') { + toast.add({ tone: 'danger', message: result.data.error }); + return; + } + + if (result.type === 'error') { + toast.add({ + tone: 'danger', + message: isEditing ? REPORT_UPDATED_SUCCESS_MESSAGE : REPORT_CREATED_SUCCESS_MESSAGE + }); + } + }; + }} + > + + + {#if actionForm?.error} + +

    {actionForm.error}

    +
    + {/if} + + {#if submitAttempted && validationIssues.length > 0} + +
    +

    {m.reports_create_validation_copy()}

    +
    + +
      + {#each validationIssues as issue (issue)} +
    • {issue}
    • + {/each} +
    +
    + {/if} + + + +
    + + + +
    + + {#if !loadingDevices && deviceOptions.length === 0} + +

    {m.reports_create_no_devices_loaded()}

    +
    + {/if} +
    +
    + + + + + + + +

    {m.reports_create_alerts_copy()}

    + +
    +
    + + + + + + + goto(resolve('/reports'))}> + + {m.action_cancel()} + + + + {#if submitting} + {m.action_saving()} + {:else if isEditing} + {m.action_save_changes()} + {:else} + {m.reports_create_submit()} + {/if} + + + + + +
    +
    + + diff --git a/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte b/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte new file mode 100644 index 00000000..d1823dff --- /dev/null +++ b/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte @@ -0,0 +1,84 @@ + + + + + {#if schedules.length === 0} + +

    {m.reports_create_empty_schedules()}

    +
    + {/if} + + {#each schedules as schedule, index (schedule.key)} +
    +
    +
    +

    {m.reports_create_schedule_heading({ index: String(index + 1) })}

    +

    {m.reports_create_schedule_copy()}

    +
    +
    + +
    + (schedule.end_of_week = checked)} + /> + (schedule.end_of_month = checked)} + /> +
    +
    + {/each} +
    +
    + + diff --git a/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte b/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte new file mode 100644 index 00000000..522da143 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte @@ -0,0 +1,199 @@ + + + + +
    +

    {m.reports_schedule_card_copy()}

    + + + +
    + + {#if schedules.length === 0} + +

    {m.reports_schedule_empty()}

    +
    + {/if} + + {#each schedules as schedule, index (schedule.key)} +
    +
    +
    +

    {m.reports_schedule_entry_heading({ index: String(index + 1) })}

    +
    + onRemove(schedule.key)}> + {m.action_remove()} + +
    + +
    + + + +
    + +
    + + +
    + +
    + (schedule.crosses_midnight = checked)} + /> + (schedule.is_enabled = checked)} + /> +
    +
    + {/each} +
    +
    + + diff --git a/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte b/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte new file mode 100644 index 00000000..00df7d31 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte @@ -0,0 +1,137 @@ + + + + +
    +

    {m.reports_create_recipients_copy()}

    + + + +
    + + {#if recipients.length === 0} + +

    {m.reports_create_empty_recipients()}

    +
    + {/if} + + {#each recipients as recipient, index (recipient.key)} +
    +
    +
    +

    {m.reports_create_recipient_heading({ index: String(index + 1) })}

    +

    {m.reports_create_recipient_copy()}

    +
    + {#if recipients.length > 1} + onRemove(recipient.key)} + > + {m.action_remove()} + + {/if} +
    + +
    + + + +
    +
    + {/each} +
    +
    + + diff --git a/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts b/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts new file mode 100644 index 00000000..93387314 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts @@ -0,0 +1,71 @@ +import { getUiLocale } from '$lib/i18n/format'; +import { m } from '$lib/paraglide/messages.js'; +import type { CwAlertPointsEditorText } from '@cropwatchdevelopment/cwui'; + +function formatOverlapLabels(labels: string[]): string { + return labels.join(getUiLocale() === 'ja' ? '、' : ', '); +} + +export function createReportAlertPointsEditorText(): CwAlertPointsEditorText { + return { + unitFieldLabel: m.reports_create_alert_points_unit_label(), + centerFieldLabel: m.reports_create_alert_points_center_label(), + nameFieldLabel: m.common_name(), + conditionFieldLabel: m.reports_create_alert_operator(), + valueFieldLabel: m.reports_create_alert_value(), + minValueFieldLabel: m.reports_create_alert_minimum(), + maxValueFieldLabel: m.reports_create_alert_maximum(), + colorFieldLabel: m.reports_create_alert_hex_color(), + addAlertPointButton: m.reports_create_add_alert(), + removePointButton: m.action_remove(), + emptyTitle: m.reports_create_alert_points_empty_title(), + emptyDescription: m.reports_create_alert_points_empty_description(), + invalidNumberError: m.reports_create_alert_points_invalid_number(), + requiredFieldError: (label) => m.reports_create_alert_points_required_field({ label }), + fieldLabelWithUnit: (label, unit) => + m.reports_create_alert_points_label_with_unit({ label, unit }), + invalidPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_invalid_preview_single() + : m.reports_create_alert_points_invalid_preview_multiple({ + count: String(count) + }), + overlapPreviewNote: (count) => + count === 1 + ? m.reports_create_alert_points_overlap_preview_single() + : m.reports_create_alert_points_overlap_preview_multiple({ + count: String(count) + }), + minEqualsMaxWarning: m.reports_create_alert_points_equal_bounds_warning(), + defaultPointName: (index) => m.reports_create_alert_heading({ index: String(index) }), + unitCelsiusLabel: '°C', + unitFahrenheitLabel: '°F', + unitKelvinLabel: '°K', + conditionEqualsLabel: m.reports_create_alert_points_condition_equals(), + conditionRangeLabel: m.reports_create_alert_points_condition_range(), + conditionLessThanLabel: m.reports_create_alert_points_condition_less_than(), + conditionLessThanOrEqualLabel: m.reports_create_alert_points_condition_less_than_or_equal(), + conditionGreaterThanLabel: m.reports_create_alert_points_condition_greater_than(), + conditionGreaterThanOrEqualLabel: + m.reports_create_alert_points_condition_greater_than_or_equal(), + pointDescriptionWaitingForValue: m.reports_create_alert_points_waiting_for_value(), + pointDescriptionWaitingForThreshold: m.reports_create_alert_points_waiting_for_threshold(), + pointDescriptionRangeMissingBounds: m.reports_create_alert_points_range_missing_bounds(), + pointDescriptionEquals: (value, unit) => + m.reports_create_alert_points_description_equals({ value, unit }), + pointDescriptionRange: (min, max, unit) => + m.reports_create_alert_points_description_range({ min, max, unit }), + pointDescriptionLessThan: (value, unit) => + m.reports_create_alert_points_description_less_than({ value, unit }), + pointDescriptionLessThanOrEqual: (value, unit) => + m.reports_create_alert_points_description_less_than_or_equal({ value, unit }), + pointDescriptionGreaterThan: (value, unit) => + m.reports_create_alert_points_description_greater_than({ value, unit }), + pointDescriptionGreaterThanOrEqual: (value, unit) => + m.reports_create_alert_points_description_greater_than_or_equal({ value, unit }), + overlapError: (labels) => + m.reports_create_alert_points_overlap_error({ + labels: formatOverlapLabels(labels) + }) + }; +} diff --git a/src/routes/reports/[report_id]/edit/report-form.types.ts b/src/routes/reports/[report_id]/edit/report-form.types.ts new file mode 100644 index 00000000..6f14bce8 --- /dev/null +++ b/src/routes/reports/[report_id]/edit/report-form.types.ts @@ -0,0 +1,54 @@ +export type SelectOption = { + disabled?: boolean; + label: string; + value: string; +}; + +export type ScheduleDraft = { + created_at: string; + dev_eui: string; + end_of_month: boolean; + end_of_week: boolean; + id: string; + is_active: boolean; + key: string; + report_id: string; + report_user_schedule_id: string; + user_id: string; +}; + +export type RecipientDraft = { + communication_method: string; + created_at: string; + email: string; + id: string; + key: string; + name: string; + report_id: string; + user_id: string; +}; + +export type DataProcessingScheduleDraft = { + crosses_midnight: boolean; + day_of_week: string; + end_time: string; + id: string; + is_enabled: boolean; + key: string; + report_id: string; + rule_type: string; + start_time: string; + timezone: string; +}; + +export type ReportDraft = { + created_at: string; + dev_eui: string; + id: string; + name: string; + report_id: string; + report_data_processing_schedules: DataProcessingScheduleDraft[]; + report_recipients: RecipientDraft[]; + report_user_schedule: ScheduleDraft[]; + user_id: string; +}; diff --git a/src/routes/reports/report-row.ts b/src/routes/reports/report-row.ts new file mode 100644 index 00000000..bb80a002 --- /dev/null +++ b/src/routes/reports/report-row.ts @@ -0,0 +1,22 @@ +import type { ReportDto } from '$lib/api/api.dtos'; + +export type ReportDeviceRelations = { + cw_devices?: { + name?: string | null; + cw_device_owners?: Array<{ + user_id?: string | null; + permission_level?: number | null; + }> | null; + cw_locations?: { + name?: string | null; + location_id?: string | number | null; + } | null; + } | null; +}; + +export type ReportRow = ReportDto & + ReportDeviceRelations & { + device_name: string; + location_name: string; + permission_level: number | null; + }; diff --git a/src/routes/rules/+page.server.ts b/src/routes/rules/+page.server.ts new file mode 100644 index 00000000..31201389 --- /dev/null +++ b/src/routes/rules/+page.server.ts @@ -0,0 +1,61 @@ +import { ApiService } from '$lib/api/api.service'; +import type { LayoutServerLoad } from '../$types'; + +export const load: LayoutServerLoad = async ({ locals, fetch }) => { + const session = locals.jwt ?? null; + const authToken = locals.jwtString ?? null; + + if (!authToken) { + return { + session, + devices: [], + totalDeviceCount: 0, + triggeredRulesCount: 0 + }; + } + + const apiServiceInstance = new ApiService({ + fetchFn: fetch, + authToken + }); + + const rules = await apiServiceInstance.getRules().catch(() => []); + + const ruleResult = rules.map((rule) => { + const deviceRecord = + rule.cw_devices && typeof rule.cw_devices === 'object' + ? (rule.cw_devices as Record) + : null; + const deviceOwners = Array.isArray(deviceRecord?.cw_device_owners) + ? (deviceRecord.cw_device_owners as Array<{ + user_id?: string | null; + permission_level?: number | null; + }>) + : []; + const locationRecord = + deviceRecord?.cw_locations && typeof deviceRecord.cw_locations === 'object' + ? (deviceRecord.cw_locations as Record) + : null; + + return { + device_name: + deviceRecord?.name && typeof deviceRecord.name === 'string' ? deviceRecord.name : '', + location_name: + typeof locationRecord?.name === 'string' && locationRecord.name.trim().length > 0 + ? locationRecord.name + : '', + hasPermission: rule.hasPermission || false, + permission_level: + deviceOwners.find((owner) => owner.user_id === session?.sub)?.permission_level ?? null, + + last_triggered: rule.last_triggered ? new Date(rule.last_triggered) : null, + ...rule + }; + }); + + return { + session, + authToken, + rules: ruleResult + }; +}; diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte new file mode 100644 index 00000000..eaabb840 --- /dev/null +++ b/src/routes/rules/+page.svelte @@ -0,0 +1,181 @@ + + + + {m.rules_page_title()} + + + + goto(resolve('/'))}> + ← {m.action_back_to_dashboard()} + + + + {#key tableKey} + + {#snippet cell(row: RuleRow, col: CwColumnDef, defaultValue: string)} + {#if col.key === 'created_at'} + {new Date(row.created_at).toLocaleString()} + {:else if col.key === 'last_triggered'} + {row.last_triggered + ? new Date(row.last_triggered).toLocaleString() + : m.common_not_available()} + {:else} + {defaultValue} + {/if} + {/snippet} + {#snippet rowActions(row: RuleRow)} +
    + {#if row.permission_level != null && row.permission_level <= 3} + + {/if} + {#if row.permission_level != null && row.permission_level <= 2} + goto(resolve('/rules/edit/[id]', { id: String(row.id) }))} + > + + + + {/if} +
    + {/snippet} + {#snippet toolbarActions()} + { + goto(resolve('/rules/create')); + }} + > + + {m.rules_create_new_rule()} + + {/snippet} +
    + {/key} +
    +
    diff --git a/src/routes/rules/DeleteRuleDialog.svelte b/src/routes/rules/DeleteRuleDialog.svelte new file mode 100644 index 00000000..4ac167a8 --- /dev/null +++ b/src/routes/rules/DeleteRuleDialog.svelte @@ -0,0 +1,64 @@ + + + (open = true)}> + + + + (open = false)}> +

    {m.rules_delete_confirmation({ name: ruleName })}

    + {#snippet actions()} +
    + (open = false)}> + + {m.action_cancel()} + + + {m.action_delete()} + +
    + {/snippet} +
    diff --git a/src/routes/rules/ViewRuleDialog.svelte b/src/routes/rules/ViewRuleDialog.svelte new file mode 100644 index 00000000..7ef8e296 --- /dev/null +++ b/src/routes/rules/ViewRuleDialog.svelte @@ -0,0 +1,188 @@ + + + (open = true)}> + + + + +
    + +
    +
    {m.common_name()}:
    +
    {row.name}
    + +
    {m.devices_device()}:
    +
    {getDeviceLabel(row)}
    + +
    {m.rules_trigger_count()}:
    +
    {row.trigger_count}
    + +
    {m.rules_notify_via()}:
    +
    {getNotificationSummary(row)}
    + +
    {m.rules_conditions()}:
    +
    + {#if criteriaPreview.length > 0} +
    + {#each criteriaPreview as preview, i (i)} + + {/each} +
    + {:else} + - + {/if} +
    +
    +
    + + {#if row.cw_rule_criteria?.length} + + +
    + {#each row.cw_rule_criteria as criterion, idx (criterion.id)} +
    +
    + + {m.rules_condition_number({ count: String(idx + 1) })} + +
    + +
    +
    +
    {m.rules_data_field()}:
    +
    {getCriterionSubjectLabel(criterion.subject)}
    +
    + +
    +
    {m.rules_operator()}:
    +
    {criterion.operator}
    +
    + +
    +
    {m.rules_trigger_value()}:
    +
    {criterion.trigger_value}
    +
    + +
    +
    {m.rules_reset_value()}:
    +
    {getResetValueLabel(criterion.reset_value)}
    +
    +
    +
    + {/each} +
    + {/if} +
    + + {#snippet actions()} + (open = false)}>{m.action_close()} + {/snippet} +
    + + diff --git a/src/routes/rules/create/+page.server.ts b/src/routes/rules/create/+page.server.ts new file mode 100644 index 00000000..62486188 --- /dev/null +++ b/src/routes/rules/create/+page.server.ts @@ -0,0 +1,17 @@ +import { ApiService } from '$lib/api/api.service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, url }) => { + const authToken = locals.jwtString ?? null; + const devEui = url.searchParams.get('dev_eui') ?? null; + + if (!authToken) { + return { devices: [], devEui }; + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + + const devices = await api.getAllDevices().catch(() => []); + + return { devices, authToken, devEui }; +}; diff --git a/src/routes/rules/create/+page.svelte b/src/routes/rules/create/+page.svelte new file mode 100644 index 00000000..abef00b9 --- /dev/null +++ b/src/routes/rules/create/+page.svelte @@ -0,0 +1,389 @@ + + + + + + + + goto(resolve('/rules'))} + >← {m.action_back()} + +
    + +
    +

    {m.rules_create_new_rule()}

    +
    + + + + + + +
    + + + + +
    + + + {#snippet leftSlot()} + 📧 + {/snippet} + +
    +
    + + + + + {#if deviceOptions.length === 0} + +

    {m.rules_no_devices_available()}

    +
    + {:else} + + {#if deviceLocked} +

    {m.rules_device_preselected()}

    + {/if} + {#if selectedDevEui} +
    + + {selectedDevEui} +
    + {/if} + {/if} +
    +
    + + + + + {#each criteria as criterion, idx (criterion.id)} +
    +
    + {m.rules_condition_number({ count: String(idx + 1) })} + {#if criteria.length > 1} + removeCriterion(criterion.id)}> + {m.action_remove()} + + {/if} +
    + +
    + + + +
    + +
    +
    + +
    +
    +
    + {/each} + + + {m.rules_add_another_condition()} + +
    +
    + + + + + {#if isFormValid} + +
    +
    {m.common_name()}:
    +
    {ruleName}
    +
    {m.devices_device()}:
    +
    {selectedDeviceName}
    +
    {m.rules_notify_via()}:
    +
    + {NOTIFIER_TYPES.find((n) => n.value === notifierType)?.label} + ({sendUsing}) → {actionRecipient} +
    +
    {m.rules_conditions()}:
    +
    +
    + {#each criteriaPreview as preview, i (i)} + + {/each} +
    +
    +
    +
    + {:else} + +

    {m.rules_complete_required_fields()}

    +
    + {/if} + + + + + goto(resolve('/rules'))} disabled={submitting}> + {m.action_cancel()} + + + {submitting ? m.rules_creating() : m.rules_create_rule()} + + +
    +
    +
    +
    + + diff --git a/src/routes/rules/edit/[id]/+page.server.ts b/src/routes/rules/edit/[id]/+page.server.ts new file mode 100644 index 00000000..da9471f8 --- /dev/null +++ b/src/routes/rules/edit/[id]/+page.server.ts @@ -0,0 +1,44 @@ +import { ApiService } from '$lib/api/api.service'; +import { m } from '$lib/paraglide/messages.js'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, fetch, params }) => { + const authToken = locals.jwtString ?? null; + const ruleId = parseInt(params.id, 10); + + if (!authToken) { + error(401, m.error_unauthorized_title()); + } + + if (isNaN(ruleId)) { + error(400, m.rules_invalid_rule_id()); + } + + const api = new ApiService({ fetchFn: fetch, authToken }); + + const [rawRule, rawDevices] = await Promise.all([ + api.getRule(ruleId).catch(() => null), + api.getAllDevices().catch(() => []) + ]); + + if (!rawRule) { + error(404, m.rules_rule_not_found()); + } + + const rule = { + ...rawRule, + cw_rule_criteria: (rawRule.cw_rule_criteria ?? []).map((criterion) => { + const parsedId = typeof criterion.id === 'number' ? criterion.id : Number(criterion.id); + + return { + ...criterion, + id: Number.isFinite(parsedId) ? parsedId : 0 + }; + }) + }; + + const devices = rawDevices; + + return { rule, devices, authToken }; +}; diff --git a/src/routes/rules/edit/[id]/+page.svelte b/src/routes/rules/edit/[id]/+page.svelte new file mode 100644 index 00000000..04546e45 --- /dev/null +++ b/src/routes/rules/edit/[id]/+page.svelte @@ -0,0 +1,394 @@ + + + + + + + +
    + +
    + goto(resolve('/rules'))}> + {m.action_back()} + +

    {m.rules_edit_rule()}

    + +
    + + + + + + +
    + + +
    + + + {#snippet leftSlot()} + 📧 + {/snippet} + +
    +
    + + + + + {#if deviceOptions.length === 0} + +

    {m.rules_no_devices_available()}

    +
    + {:else} + + {#if selectedDevEui} +
    + + {selectedDevEui} +
    + {/if} + {/if} +
    +
    + + + + + {#each criteria as criterion, idx (criterion.id)} +
    +
    + {m.rules_condition_number({ count: String(idx + 1) })} + {#if criteria.length > 1} + removeCriterion(criterion.id)}> + {m.action_remove()} + + {/if} +
    + +
    + + + +
    + +
    +
    + +

    + {m.rules_reset_value_help()} +

    +
    +
    +
    + {/each} + +
    +
    + + + + + {#if isFormValid} + +
    +
    {m.common_name()}:
    +
    {ruleName}
    +
    {m.devices_device()}:
    +
    {selectedDeviceName}
    +
    {m.rules_notify_via()}:
    +
    + {NOTIFIER_TYPES.find((n) => n.value === notifierType)?.label} + ({sendUsing}) → {actionRecipient} +
    +
    {m.rules_conditions()}:
    +
    +
    + {#each criteriaPreview as preview, i (i)} + + {/each} +
    +
    +
    +
    + {:else} + +

    {m.rules_complete_required_fields()}

    +
    + {/if} + + + + + goto(resolve('/rules'))} disabled={submitting}> + {m.action_cancel()} + + + + {submitting ? m.action_saving() : m.action_save_changes()} + + +
    +
    +
    +
    + + diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts new file mode 100644 index 00000000..efe13805 --- /dev/null +++ b/src/routes/settings/+page.server.ts @@ -0,0 +1,172 @@ +import { getLocale } from '$lib/paraglide/runtime'; +import type { PageServerLoad } from './$types'; + +type Option = { + label: string; + value: string; +}; + +type SupportedLocale = 'ja' | 'en'; + +type PreferenceDraft = { + language: SupportedLocale; + theme: 'dark' | 'light' | 'system'; + temperatureUnit: string; + ecUnit: string; + soilMoistureUnit: string; + rainfallUnit: string; + windSpeedUnit: string; + pressureUnit: string; + waterDepthUnit: string; + co2Unit: string; + distanceUnit: string; + areaUnit: string; + dateFormat: string; + timeFormat: string; + decimalSeparator: string; + compactDashboard: boolean; + showDerivedMetrics: boolean; + includeUnitsInExports: boolean; + highlightAlertThresholds: boolean; +}; + +const languageOptions: Option[] = [ + { label: 'Japanese', value: 'ja' }, + { label: 'English', value: 'en' } +]; + +const temperatureUnitOptions: Option[] = [ + { label: 'Celsius (C)', value: 'celsius' }, + { label: 'Fahrenheit (F)', value: 'fahrenheit' }, + { label: 'Kelvin (K)', value: 'kelvin' } +]; + +const ecUnitOptions: Option[] = [ + { label: 'uS/cm', value: 'us_cm' }, + { label: 'mS/cm', value: 'ms_cm' }, + { label: 'dS/m', value: 'ds_m' }, + { label: 'dS/cm', value: 'ds_cm' } +]; + +const soilMoistureUnitOptions: Option[] = [ + { label: 'VWC (%)', value: 'vwc_percent' }, + { label: 'Relative saturation (%)', value: 'relative_percent' }, + { label: 'kPa', value: 'kpa' }, + { label: 'centibar', value: 'centibar' } +]; + +const rainfallUnitOptions: Option[] = [ + { label: 'Millimeters (mm)', value: 'mm' }, + { label: 'Centimeters (cm)', value: 'cm' }, + { label: 'Inches (in)', value: 'in' } +]; + +const windSpeedUnitOptions: Option[] = [ + { label: 'Meters per second (m/s)', value: 'm_s' }, + { label: 'Kilometers per hour (km/h)', value: 'km_h' }, + { label: 'Miles per hour (mph)', value: 'mph' }, + { label: 'Knots (kt)', value: 'kt' } +]; + +const pressureUnitOptions: Option[] = [ + { label: 'hPa', value: 'hpa' }, + { label: 'kPa', value: 'kpa' }, + { label: 'bar', value: 'bar' }, + { label: 'PSI', value: 'psi' } +]; + +const waterDepthUnitOptions: Option[] = [ + { label: 'Centimeters (cm)', value: 'cm' }, + { label: 'Meters (m)', value: 'm' }, + { label: 'Inches (in)', value: 'in' }, + { label: 'Feet (ft)', value: 'ft' } +]; + +const co2UnitOptions: Option[] = [ + { label: 'PPM', value: 'ppm' }, + { label: 'mg/m3', value: 'mg_m3' } +]; + +const distanceUnitOptions: Option[] = [ + { label: 'Kilometers (km)', value: 'km' }, + { label: 'Miles (mi)', value: 'mi' } +]; + +const areaUnitOptions: Option[] = [ + { label: 'Hectares (ha)', value: 'hectares' }, + { label: 'Acres (ac)', value: 'acres' }, + { label: 'Square meters (m2)', value: 'square_meters' } +]; + +const dateFormatOptions: Option[] = [ + { label: 'YYYY-MM-DD', value: 'yyyy_mm_dd' }, + { label: 'DD/MM/YYYY', value: 'dd_mm_yyyy' }, + { label: 'MM/DD/YYYY', value: 'mm_dd_yyyy' } +]; + +const timeFormatOptions: Option[] = [ + { label: '24-hour', value: '24h' }, + { label: '12-hour', value: '12h' } +]; + +const decimalSeparatorOptions: Option[] = [ + { label: 'Dot (1,234.56)', value: 'dot' }, + { label: 'Comma (1.234,56)', value: 'comma' } +]; + +const readString = (value: unknown): string => { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +}; + +const createDefaultPreferences = (locale: string): PreferenceDraft => ({ + language: locale.startsWith('en') ? 'en' : 'ja', + theme: 'system', + temperatureUnit: 'celsius', + ecUnit: 'us_cm', + soilMoistureUnit: 'vwc_percent', + rainfallUnit: 'mm', + windSpeedUnit: 'm_s', + pressureUnit: 'hpa', + waterDepthUnit: 'cm', + co2Unit: 'ppm', + distanceUnit: 'km', + areaUnit: 'hectares', + dateFormat: 'yyyy_mm_dd', + timeFormat: '24h', + decimalSeparator: 'dot', + compactDashboard: false, + showDerivedMetrics: true, + includeUnitsInExports: true, + highlightAlertThresholds: true +}); + +export const load: PageServerLoad = async ({ locals }) => { + const session = locals.jwt ?? null; + const locale = getLocale(); + + return { + email: readString(session?.email) || null, + role: readString(session?.role) || null, + preferences: createDefaultPreferences(locale), + options: { + language: languageOptions, + temperature: temperatureUnitOptions, + ec: ecUnitOptions, + soilMoisture: soilMoistureUnitOptions, + rainfall: rainfallUnitOptions, + windSpeed: windSpeedUnitOptions, + pressure: pressureUnitOptions, + waterDepth: waterDepthUnitOptions, + co2: co2UnitOptions, + distance: distanceUnitOptions, + area: areaUnitOptions, + dateFormat: dateFormatOptions, + timeFormat: timeFormatOptions, + decimalSeparator: decimalSeparatorOptions + } + }; +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 00000000..c692765d --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,350 @@ + + + + {m.nav_settings()} - CropWatch + + + +
    event.preventDefault()}> +
    + + +
    + + + + + + + +
    + +
    + + +
    +
    +
    +
    + +
    + + +
    +
    +

    Theme mode

    +

    {themeLabel}

    +
    + +
    + +
    + + + +
    + +
    + +
    +
    +
    +
    + +
    + + +
    + + + + + + + + + + + + + + + +
    + +
    + + + +
    +
    +
    +
    + +
    + + +
    + + + +
    + + + + Reset preview + + Save settings + +
    +
    +
    +
    +
    + + diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 00000000..362f94f2 --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,140 @@ +/// +/// +/// +/// + +import { build, files, prerendered, version } from '$service-worker'; + +const self = globalThis.self as ServiceWorkerGlobalScope; +const base = self.location.pathname.split('/').slice(0, -1).join('/'); + +const STATIC_CACHE = `cropwatch-static-${version}`; +const RUNTIME_CACHE = `cropwatch-runtime-${version}`; +const OFFLINE_PATH = `${base}/offline`; +const PRECACHE_URLS = [...new Set([...build, ...files, ...prerendered])]; +const PRECACHE_URL_SET = new Set(PRECACHE_URLS); + +function shouldCacheRuntimeAsset(request: Request, url: URL): boolean { + if (url.origin !== self.location.origin) return false; + if (url.pathname.includes('__data.json')) return false; + if (url.pathname.startsWith(`${base}/api/`)) return false; + if (url.pathname.startsWith('/src/')) return false; + if (url.pathname.startsWith('/@')) return false; + if (url.pathname.includes('/node_modules/')) return false; + + return ['font', 'image', 'manifest', 'script', 'style', 'worker'].includes(request.destination); +} + +function isCacheableResponse(response: Response): boolean { + const cacheControl = response.headers.get('cache-control') ?? ''; + return response.ok && !cacheControl.includes('no-store'); +} + +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(STATIC_CACHE); + await cache.addAll(PRECACHE_URLS); + })() + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const expectedCaches = new Set([STATIC_CACHE, RUNTIME_CACHE]); + + for (const cacheName of await caches.keys()) { + if (!expectedCaches.has(cacheName)) { + await caches.delete(cacheName); + } + } + + await self.clients.claim(); + })() + ); +}); + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + void self.skipWaiting(); + } +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + + const url = new URL(event.request.url); + + if (url.origin !== self.location.origin) { + return; + } + + if (PRECACHE_URL_SET.has(url.pathname)) { + event.respondWith( + (async () => { + const cache = await caches.open(STATIC_CACHE); + const cachedResponse = await cache.match(event.request); + + if (cachedResponse) { + return cachedResponse; + } + + const networkResponse = await fetch(event.request); + if (isCacheableResponse(networkResponse)) { + await cache.put(event.request, networkResponse.clone()); + } + + return networkResponse; + })() + ); + return; + } + + if (event.request.mode === 'navigate') { + event.respondWith( + (async () => { + try { + return await fetch(event.request); + } catch (error) { + const cache = await caches.open(STATIC_CACHE); + const offlineResponse = await cache.match(OFFLINE_PATH); + + if (offlineResponse) { + return offlineResponse; + } + + throw error; + } + })() + ); + return; + } + + if (!shouldCacheRuntimeAsset(event.request, url)) { + return; + } + + event.respondWith( + (async () => { + const cache = await caches.open(RUNTIME_CACHE); + const cachedResponse = await cache.match(event.request); + const networkPromise = fetch(event.request).then(async (response) => { + if (isCacheableResponse(response)) { + await cache.put(event.request, response.clone()); + } + + return response; + }); + + if (cachedResponse) { + void networkPromise.catch(() => undefined); + return cachedResponse; + } + + return networkPromise; + })() + ); +}); diff --git a/static/build-info.json b/static/build-info.json deleted file mode 100644 index 6ba75f00..00000000 --- a/static/build-info.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "commit": "95bb936", - "branch": "polish", - "author": "Kevin Cantrell", - "date": "2025-11-09T11:24:47.996Z", - "builder": "kevin@kevin-desktop", - "ipAddress": "192.168.1.100", - "timestamp": 1762687487997 -} diff --git a/static/build-info.txt b/static/build-info.txt deleted file mode 100644 index 5f69a193..00000000 --- a/static/build-info.txt +++ /dev/null @@ -1,8 +0,0 @@ -=== Build Info === -Commit : 03cf732 -Branch : pwa -Author : Kevin Cantrell -Date : 2025-07-03T12:09:21.667Z -Builder : kevin@kevin-desktop -IP Address : 192.168.1.100 -================== diff --git a/static/favicon.ico b/static/favicon.ico index 52cbb4bb..bc912317 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/static/favicon.png b/static/favicon.png deleted file mode 100644 index 57e0d8cb..00000000 Binary files a/static/favicon.png and /dev/null differ diff --git a/static/favicon.svg b/static/favicon.svg index e399c925..cc5dc66a 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1,182 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file +svelte-logo \ No newline at end of file diff --git a/static/files/Sensirion_Humidity_Sensors_SHTxx_Calibration_Certification.pdf b/static/files/Sensirion_Humidity_Sensors_SHTxx_Calibration_Certification.pdf new file mode 100644 index 00000000..a633ffcc Binary files /dev/null and b/static/files/Sensirion_Humidity_Sensors_SHTxx_Calibration_Certification.pdf differ diff --git a/static/fonts/NotoSansJP-Regular.otf b/static/fonts/NotoSansJP-Regular.otf deleted file mode 100644 index 1fc6d417..00000000 --- a/static/fonts/NotoSansJP-Regular.otf +++ /dev/null @@ -1,2132 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - -
    - Skip to content - - - - - - - - - - - -
    -
    - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - - - -
    - - - - - -
    - - - - - - - - - -
    -
    - - - -
    -
    - -
    -
    - 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
    -
    - -
    -
    - -
    - - -
    -
    - -
    - -
    - -
    - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    - - - diff --git a/static/fonts/NotoSansJP-Regular.ttf b/static/fonts/NotoSansJP-Regular.ttf deleted file mode 100644 index b2dad730..00000000 Binary files a/static/fonts/NotoSansJP-Regular.ttf and /dev/null differ diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png index f253a5ac..6bb96bce 100644 Binary files a/static/icons/apple-touch-icon.png and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/christmas_cropwatch.svg b/static/icons/christmas_cropwatch.svg new file mode 100644 index 00000000..933544f8 --- /dev/null +++ b/static/icons/christmas_cropwatch.svg @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icons/favico.ico b/static/icons/favico.ico new file mode 100644 index 00000000..bc912317 Binary files /dev/null and b/static/icons/favico.ico differ diff --git a/static/icons/maskable-icon-192x192.png b/static/icons/maskable-icon-192x192.png new file mode 100644 index 00000000..2470d4d0 Binary files /dev/null and b/static/icons/maskable-icon-192x192.png differ diff --git a/static/icons/maskable-icon-512x512.png b/static/icons/maskable-icon-512x512.png new file mode 100644 index 00000000..40581914 Binary files /dev/null and b/static/icons/maskable-icon-512x512.png differ diff --git a/static/login-background-images/beach.jpg b/static/login-background-images/beach.jpg deleted file mode 100644 index c7a567be..00000000 Binary files a/static/login-background-images/beach.jpg and /dev/null differ diff --git a/static/login-background-images/field1.jpg b/static/login-background-images/field1.jpg deleted file mode 100644 index 54cd2ffa..00000000 Binary files a/static/login-background-images/field1.jpg and /dev/null differ diff --git a/static/login-background-images/field2.jpg b/static/login-background-images/field2.jpg deleted file mode 100644 index 33303ff0..00000000 Binary files a/static/login-background-images/field2.jpg and /dev/null differ diff --git a/static/login-background-images/greenhouse.jpg b/static/login-background-images/greenhouse.jpg deleted file mode 100644 index 0ed92e99..00000000 Binary files a/static/login-background-images/greenhouse.jpg and /dev/null differ diff --git a/static/offline.html b/static/offline.html deleted file mode 100644 index 2fc0be8c..00000000 --- a/static/offline.html +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - Offline | CropWatch - - - - -
    - - - -
    🌾
    -

    You're Offline

    - -
    -

    It looks like you're not connected to the internet. CropWatch needs an internet connection to sync your IoT device data and provide real-time monitoring.

    -
    - -
    - 📡 No Internet Connection
    - Please check your network connection and try again. -
    - -
    - - - -
    - -
    - 📴 Currently offline -
    -
    - - - - diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 00000000..b6dd6670 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/static/screenshots/pwa/.gitkeep b/static/screenshots/pwa/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/static/screenshots/pwa/.gitkeep @@ -0,0 +1 @@ + diff --git a/static/screenshots/screenshot-1280x720.png b/static/screenshots/screenshot-1280x720.png deleted file mode 100644 index 2f14725e..00000000 Binary files a/static/screenshots/screenshot-1280x720.png and /dev/null differ diff --git a/static/screenshots/screenshot-dashboard-1280x720.png b/static/screenshots/screenshot-dashboard-1280x720.png deleted file mode 100644 index b5f1ae05..00000000 Binary files a/static/screenshots/screenshot-dashboard-1280x720.png and /dev/null differ diff --git a/static/test-sample-report.pdf b/static/test-sample-report.pdf deleted file mode 100644 index b1ff7cf7..00000000 Binary files a/static/test-sample-report.pdf and /dev/null differ diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest deleted file mode 100644 index 11335d2f..00000000 --- a/supabase/.temp/cli-latest +++ /dev/null @@ -1 +0,0 @@ -v2.54.11 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version deleted file mode 100644 index a091f261..00000000 --- a/supabase/.temp/gotrue-version +++ /dev/null @@ -1 +0,0 @@ -v2.176.1 \ No newline at end of file diff --git a/supabase/.temp/pooler-url b/supabase/.temp/pooler-url deleted file mode 100644 index 6325e4a4..00000000 --- a/supabase/.temp/pooler-url +++ /dev/null @@ -1 +0,0 @@ -postgresql://postgres.uvtmwyhdhofyumwglxzr:[YOUR-PASSWORD]@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres \ No newline at end of file diff --git a/supabase/.temp/postgres-version b/supabase/.temp/postgres-version deleted file mode 100644 index d1330369..00000000 --- a/supabase/.temp/postgres-version +++ /dev/null @@ -1 +0,0 @@ -15.8.1.073 \ No newline at end of file diff --git a/supabase/.temp/project-ref b/supabase/.temp/project-ref deleted file mode 100644 index d96e98b3..00000000 --- a/supabase/.temp/project-ref +++ /dev/null @@ -1 +0,0 @@ -uvtmwyhdhofyumwglxzr \ No newline at end of file diff --git a/supabase/.temp/rest-version b/supabase/.temp/rest-version deleted file mode 100644 index 2392826e..00000000 --- a/supabase/.temp/rest-version +++ /dev/null @@ -1 +0,0 @@ -v12.2.3 \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version deleted file mode 100644 index 04f88252..00000000 --- a/supabase/.temp/storage-version +++ /dev/null @@ -1 +0,0 @@ -custom-metadata \ No newline at end of file diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index c62084ee..00000000 --- a/supabase/config.toml +++ /dev/null @@ -1,308 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "svDesignPattern" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 15 - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.migrations] -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = [] - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[realtime] -enabled = true -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = true - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = true -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -# Use Clerk as a third-party provider alongside Supabase Auth. -[auth.third_party.clerk] -enabled = false -# Obtain from https://clerk.com/setup/supabase -# domain = "example.clerk.accounts.dev" - -[edge_runtime] -enabled = true -# Configure one of the supported request policies: `oneshot`, `per_worker`. -# Use `oneshot` for hot reload, or `per_worker` for load testing. -policy = "oneshot" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8083 -# The Deno major version to use. -deno_version = 1 - -# [edge_runtime.secrets] -# secret_key = "env(SECRET_VALUE)" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20250105000000_create_subscription_tables.sql b/supabase/migrations/20250105000000_create_subscription_tables.sql deleted file mode 100644 index 80df151b..00000000 --- a/supabase/migrations/20250105000000_create_subscription_tables.sql +++ /dev/null @@ -1,132 +0,0 @@ --- Create subscriptions table to track user subscriptions -CREATE TABLE IF NOT EXISTS subscriptions ( - id TEXT PRIMARY KEY, - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - customer_id TEXT NOT NULL, - stripe_subscription_id TEXT UNIQUE NOT NULL, - stripe_price_id TEXT NOT NULL, - status TEXT NOT NULL, - current_period_start TIMESTAMP WITH TIME ZONE, - current_period_end TIMESTAMP WITH TIME ZONE, - cancel_at_period_end BOOLEAN DEFAULT FALSE, - canceled_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create subscription_items table for detailed subscription line items -CREATE TABLE IF NOT EXISTS subscription_items ( - id TEXT PRIMARY KEY, - subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, - stripe_subscription_item_id TEXT UNIQUE NOT NULL, - stripe_price_id TEXT NOT NULL, - quantity INTEGER DEFAULT 1, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create payments table to track payment history -CREATE TABLE IF NOT EXISTS payments ( - id TEXT PRIMARY KEY, - subscription_id TEXT REFERENCES subscriptions(id) ON DELETE SET NULL, - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - stripe_payment_intent_id TEXT UNIQUE NOT NULL, - stripe_invoice_id TEXT, - amount INTEGER NOT NULL, - currency TEXT NOT NULL DEFAULT 'usd', - status TEXT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create customers table to store Stripe customer data -CREATE TABLE IF NOT EXISTS customers ( - id TEXT PRIMARY KEY, - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - stripe_customer_id TEXT UNIQUE NOT NULL, - email TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create indexes for better performance -CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); -CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id); -CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id); -CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); - -CREATE INDEX IF NOT EXISTS idx_subscription_items_subscription_id ON subscription_items(subscription_id); -CREATE INDEX IF NOT EXISTS idx_subscription_items_stripe_subscription_item_id ON subscription_items(stripe_subscription_item_id); - -CREATE INDEX IF NOT EXISTS idx_payments_subscription_id ON payments(subscription_id); -CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); -CREATE INDEX IF NOT EXISTS idx_payments_stripe_payment_intent_id ON payments(stripe_payment_intent_id); - -CREATE INDEX IF NOT EXISTS idx_customers_user_id ON customers(user_id); -CREATE INDEX IF NOT EXISTS idx_customers_stripe_customer_id ON customers(stripe_customer_id); - --- Enable RLS (Row Level Security) -ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; -ALTER TABLE subscription_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE payments ENABLE ROW LEVEL SECURITY; -ALTER TABLE customers ENABLE ROW LEVEL SECURITY; - --- Create RLS policies --- Users can only see their own subscriptions -CREATE POLICY "Users can view their own subscriptions" ON subscriptions - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert their own subscriptions" ON subscriptions - FOR INSERT WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update their own subscriptions" ON subscriptions - FOR UPDATE USING (auth.uid() = user_id); - --- Users can only see their own subscription items -CREATE POLICY "Users can view their own subscription items" ON subscription_items - FOR SELECT USING ( - EXISTS ( - SELECT 1 FROM subscriptions - WHERE subscriptions.id = subscription_items.subscription_id - AND subscriptions.user_id = auth.uid() - ) - ); - --- Users can only see their own payments -CREATE POLICY "Users can view their own payments" ON payments - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert their own payments" ON payments - FOR INSERT WITH CHECK (auth.uid() = user_id); - --- Users can only see their own customer records -CREATE POLICY "Users can view their own customer records" ON customers - FOR SELECT USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert their own customer records" ON customers - FOR INSERT WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update their own customer records" ON customers - FOR UPDATE USING (auth.uid() = user_id); - --- Update timestamp function -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Create triggers for updating updated_at columns -CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_subscription_items_updated_at BEFORE UPDATE ON subscription_items - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_payments_updated_at BEFORE UPDATE ON payments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_customers_updated_at BEFORE UPDATE ON customers - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/supabase/migrations/20250221120000_update_report_proc_timezone.sql b/supabase/migrations/20250221120000_update_report_proc_timezone.sql deleted file mode 100644 index 055ba718..00000000 --- a/supabase/migrations/20250221120000_update_report_proc_timezone.sql +++ /dev/null @@ -1,136 +0,0 @@ --- 1) Create the function -CREATE OR REPLACE FUNCTION get_filtered_device_report_data_multi( - p_dev_id TEXT, - p_start_time TIMESTAMPTZ, - p_end_time TIMESTAMPTZ, - p_interval_minutes INTEGER, - p_columns TEXT[], -- e.g. ['temperature_c','humidity'] - p_ops TEXT[], -- e.g. ['>','BETWEEN'] - p_mins DOUBLE PRECISION[], -- e.g. [-22, 55] - p_maxs DOUBLE PRECISION[] -- e.g. [NULL, 65] - p_timezone TEXT DEFAULT 'UTC' -) -RETURNS SETOF JSONB -LANGUAGE plpgsql -AS $function$ -DECLARE - v_target_table TEXT; - candidate_cols TEXT[]; - final_cols TEXT[] := ARRAY[]::TEXT[]; - col TEXT; - has_nonnull BOOLEAN; - column_list TEXT; - i INT; - cond_clauses TEXT[] := ARRAY[]::TEXT[]; - exceptions_where TEXT; - sql TEXT; - excluded_cols TEXT[] := ARRAY[ - 'dev_eui','smoke_detected','vape_detected','battery_level','is_simulated' - ]; -BEGIN - -- Ensure timestamptz fields respect the caller's timezone when converted to text/JSON - PERFORM set_config('TimeZone', COALESCE(NULLIF(p_timezone, ''), 'UTC'), true); - - -- 1) lookup table name - SELECT cdt.data_table_v2 - INTO v_target_table - FROM cw_devices dev - JOIN cw_device_type cdt ON dev."type" = cdt.id - WHERE dev.dev_eui = p_dev_id; - IF v_target_table IS NULL THEN - RAISE EXCEPTION 'No data table for dev_eui=%', p_dev_id; - END IF; - - -- 2) gather actual columns - SELECT array_agg(column_name) - INTO candidate_cols - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = v_target_table - AND column_name <> ALL(excluded_cols); - IF candidate_cols IS NULL THEN - RAISE EXCEPTION 'No columns in %', v_target_table; - END IF; - - -- 3) filter to only columns with data in the window - FOREACH col IN ARRAY candidate_cols LOOP - EXECUTE format( - 'SELECT EXISTS(SELECT 1 FROM %I WHERE dev_eui = %L AND created_at BETWEEN %L AND %L AND %I IS NOT NULL)', - v_target_table, p_dev_id, p_start_time, p_end_time, col - ) INTO has_nonnull; - IF has_nonnull THEN - final_cols := final_cols || quote_ident(col); - END IF; - END LOOP; - IF array_length(final_cols,1) IS NULL THEN - RAISE EXCEPTION 'No populated columns in %', v_target_table; - END IF; - column_list := array_to_string(final_cols, ', '); - - -- 4) build exception clauses from the four parallel arrays - IF NOT ( - array_length(p_columns,1) = array_length(p_ops,1) - AND array_length(p_columns,1) = array_length(p_mins,1) - AND array_length(p_columns,1) = array_length(p_maxs,1) - ) THEN - RAISE EXCEPTION 'Array lengths for columns/ops/mins/maxs must match'; - END IF; - - FOR i IN 1 .. array_length(p_columns,1) LOOP - IF p_ops[i] ILIKE 'between' AND p_maxs[i] IS NOT NULL THEN - cond_clauses := cond_clauses || - format('%I BETWEEN %L AND %L', p_columns[i], p_mins[i], p_maxs[i]); - ELSE - cond_clauses := cond_clauses || - format('%I %s %L', p_columns[i], p_ops[i], p_mins[i]); - END IF; - END LOOP; - exceptions_where := array_to_string(cond_clauses, ' OR '); - - -- 5) assemble & run the dynamic SQL - sql := format($query$ - WITH filtered AS ( - SELECT %s - FROM %I - WHERE dev_eui = %L - AND created_at BETWEEN %L AND %L - ), - sampled AS ( - SELECT *, - FLOOR( - EXTRACT(EPOCH FROM created_at - %L::timestamptz) - / (%s * 60) - ) AS bucket - FROM filtered - ), - dedup AS ( - SELECT DISTINCT ON (bucket) %s - FROM sampled - ORDER BY bucket, created_at DESC - ), - exceptions AS ( - SELECT %s - FROM filtered - WHERE %s - ) - SELECT to_jsonb(x) - FROM ( - SELECT * FROM dedup - UNION - SELECT * FROM exceptions - ) x - ORDER BY created_at - $query$, - column_list, -- for filtered - v_target_table, - p_dev_id, p_start_time, p_end_time, - p_start_time, -- for bucket calc - p_interval_minutes, - column_list, -- for dedup - column_list, -- for exceptions - exceptions_where -- combined WHERE clause - ); - - RETURN QUERY EXECUTE sql; -END; -$function$; diff --git a/supabase/migrations/20250224120000_create_report_proc_v2.sql b/supabase/migrations/20250224120000_create_report_proc_v2.sql deleted file mode 100644 index 87236bd2..00000000 --- a/supabase/migrations/20250224120000_create_report_proc_v2.sql +++ /dev/null @@ -1,133 +0,0 @@ -DROP FUNCTION IF EXISTS public.get_filtered_device_report_data_multi_v2( - TEXT, TIMESTAMPTZ, TIMESTAMPTZ, INTEGER, TEXT[], TEXT[], DOUBLE PRECISION[], DOUBLE PRECISION[], TEXT -); - -CREATE OR REPLACE FUNCTION get_filtered_device_report_data_multi_v2( - p_dev_id TEXT, - p_start_time TIMESTAMPTZ, - p_end_time TIMESTAMPTZ, - p_interval_minutes INTEGER, - p_columns TEXT[], - p_ops TEXT[], - p_mins DOUBLE PRECISION[], - p_maxs DOUBLE PRECISION[], - p_timezone TEXT DEFAULT 'UTC' -) -RETURNS SETOF JSONB -LANGUAGE plpgsql -AS $function$ -DECLARE - v_target_table TEXT; - candidate_cols TEXT[]; - final_cols TEXT[] := ARRAY[]::TEXT[]; - col TEXT; - has_nonnull BOOLEAN; - column_list TEXT; - i INT; - cond_clauses TEXT[] := ARRAY[]::TEXT[]; - exceptions_where TEXT; - sql TEXT; - excluded_cols TEXT[] := ARRAY[ - 'dev_eui','smoke_detected','vape_detected','battery_level','is_simulated' - ]; -BEGIN - PERFORM set_config('TimeZone', COALESCE(NULLIF(p_timezone, ''), 'UTC'), true); - - SELECT cdt.data_table_v2 - INTO v_target_table - FROM cw_devices dev - JOIN cw_device_type cdt ON dev."type" = cdt.id - WHERE dev.dev_eui = p_dev_id; - IF v_target_table IS NULL THEN - RAISE EXCEPTION 'No data table for dev_eui=%', p_dev_id; - END IF; - - SELECT array_agg(column_name) - INTO candidate_cols - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = v_target_table - AND column_name <> ALL(excluded_cols); - IF candidate_cols IS NULL THEN - RAISE EXCEPTION 'No columns in %', v_target_table; - END IF; - - FOREACH col IN ARRAY candidate_cols LOOP - EXECUTE format( - 'SELECT EXISTS(SELECT 1 FROM %I WHERE dev_eui = %L AND created_at BETWEEN %L AND %L AND %I IS NOT NULL)', - v_target_table, p_dev_id, p_start_time, p_end_time, col - ) INTO has_nonnull; - IF has_nonnull THEN - final_cols := final_cols || quote_ident(col); - END IF; - END LOOP; - IF array_length(final_cols,1) IS NULL THEN - RAISE EXCEPTION 'No populated columns in %', v_target_table; - END IF; - column_list := array_to_string(final_cols, ', '); - - IF NOT ( - array_length(p_columns,1) = array_length(p_ops,1) - AND array_length(p_columns,1) = array_length(p_mins,1) - AND array_length(p_columns,1) = array_length(p_maxs,1) - ) THEN - RAISE EXCEPTION 'Array lengths for columns/ops/mins/maxs must match'; - END IF; - - FOR i IN 1 .. array_length(p_columns,1) LOOP - IF p_ops[i] ILIKE 'between' AND p_maxs[i] IS NOT NULL THEN - cond_clauses := cond_clauses || - format('%I BETWEEN %L AND %L', p_columns[i], p_mins[i], p_maxs[i]); - ELSE - cond_clauses := cond_clauses || - format('%I %s %L', p_columns[i], p_ops[i], p_mins[i]); - END IF; - END LOOP; - exceptions_where := array_to_string(cond_clauses, ' OR '); - - sql := format($query$ - WITH filtered AS ( - SELECT %s - FROM %I - WHERE dev_eui = %L - AND created_at BETWEEN %L AND %L - ), - sampled AS ( - SELECT *, - FLOOR( - EXTRACT(EPOCH FROM created_at - %L::timestamptz) - / (%s * 60) - ) AS bucket - FROM filtered - ), - dedup AS ( - SELECT DISTINCT ON (bucket) %s - FROM sampled - ORDER BY bucket, created_at DESC - ), - exceptions AS ( - SELECT %s - FROM filtered - WHERE %s - ) - SELECT to_jsonb(x) - FROM ( - SELECT * FROM dedup - UNION - SELECT * FROM exceptions - ) x - ORDER BY created_at - $query$, - column_list, - v_target_table, - p_dev_id, p_start_time, p_end_time, - p_start_time, - p_interval_minutes, - column_list, - column_list, - exceptions_where - ); - - RETURN QUERY EXECUTE sql; -END; -$function$; diff --git a/supabase/migrations/20250503121200_device_data_optimizations.sql b/supabase/migrations/20250503121200_device_data_optimizations.sql deleted file mode 100644 index ac64af7a..00000000 --- a/supabase/migrations/20250503121200_device_data_optimizations.sql +++ /dev/null @@ -1,112 +0,0 @@ --- Function to get all column names for a specific table -CREATE OR REPLACE FUNCTION public.get_table_columns(table_name text) -RETURNS text[] AS $$ -DECLARE - columns text[]; -BEGIN - SELECT array_agg(column_name::text) INTO columns - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1; - - RETURN columns; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Function to get latest device data with specified columns for performance -CREATE OR REPLACE FUNCTION public.get_latest_device_data( - p_table_name text, - p_dev_eui text, - p_columns text DEFAULT '*' -) -RETURNS SETOF json AS $$ -DECLARE - query text; -BEGIN - -- Construct a query with the specified columns - query := format( - 'SELECT %s FROM %I WHERE dev_eui = %L ORDER BY created_at DESC LIMIT 1', - p_columns, - p_table_name, - p_dev_eui - ); - - -- Return the result of the query as JSON - RETURN QUERY EXECUTE query; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Function to get device data within a date range with pagination and column selection -CREATE OR REPLACE FUNCTION public.get_device_data_by_date_range( - p_table_name text, - p_dev_eui text, - p_start_date timestamp with time zone, - p_end_date timestamp with time zone, - p_limit integer DEFAULT 50, - p_columns text DEFAULT '*' -) -RETURNS SETOF json AS $$ -DECLARE - query text; -BEGIN - -- Construct a query with the specified columns and date range - query := format( - 'SELECT %s FROM %I - WHERE dev_eui = %L - AND created_at >= %L - AND created_at <= %L - ORDER BY created_at DESC - LIMIT %s', - p_columns, - p_table_name, - p_dev_eui, - p_start_date, - p_end_date, - p_limit - ); - - -- Return the result of the query as JSON - RETURN QUERY EXECUTE query; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Add an index to improve performance on frequently queried large tables if they don't exist -DO $$ -BEGIN - -- Check if index exists on cw_air_data - IF NOT EXISTS ( - SELECT 1 FROM pg_indexes - WHERE tablename = 'cw_air_data' AND indexname = 'cw_air_data_dev_eui_created_at_idx' - ) THEN - CREATE INDEX cw_air_data_dev_eui_created_at_idx ON public.cw_air_data(dev_eui, created_at DESC); - END IF; - - -- Check if index exists on cw_air_data_hpy - IF NOT EXISTS ( - SELECT 1 FROM pg_indexes - WHERE tablename = 'cw_air_data_hpy' AND indexname = 'cw_air_data_hpy_dev_eui_created_at_idx' - ) THEN - CREATE INDEX cw_air_data_hpy_dev_eui_created_at_idx ON public.cw_air_data_hpy(dev_eui, created_at DESC); - END IF; - - -- Check if index exists on cw_soil_data - IF NOT EXISTS ( - SELECT 1 FROM pg_indexes - WHERE tablename = 'cw_soil_data' AND indexname = 'cw_soil_data_dev_eui_created_at_idx' - ) THEN - CREATE INDEX cw_soil_data_dev_eui_created_at_idx ON public.cw_soil_data(dev_eui, created_at DESC); - END IF; - - -- Check if index exists on cw_soil_data_hpy - IF NOT EXISTS ( - SELECT 1 FROM pg_indexes - WHERE tablename = 'cw_soil_data_hpy' AND indexname = 'cw_soil_data_hpy_dev_eui_created_at_idx' - ) THEN - CREATE INDEX cw_soil_data_hpy_dev_eui_created_at_idx ON public.cw_soil_data_hpy(dev_eui, created_at DESC); - END IF; -END$$; - --- Grant necessary permissions -GRANT EXECUTE ON FUNCTION public.get_table_columns(text) TO anon, authenticated, service_role; -GRANT EXECUTE ON FUNCTION public.get_latest_device_data(text, text, text) TO anon, authenticated, service_role; -GRANT EXECUTE ON FUNCTION public.get_device_data_by_date_range(text, text, timestamp with time zone, timestamp with time zone, integer, text) TO anon, authenticated, service_role; \ No newline at end of file diff --git a/svelte.config.js b/svelte.config.js index 9f1ee43a..edd7c3c7 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,19 +1,19 @@ import adapter from '@sveltejs/adapter-vercel'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +/** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter({ - regions: ['hnd1', 'kix1'], // Optional: Specify Vercel regions - maxDuration: 300 // Set the maximum duration (in seconds) - }), + adapter: adapter(), serviceWorker: { - register: false // Let PWA plugin handle registration + register: false } }, vitePlugin: { - inspector: true + inspector: { + toggleKeyCombo: 'alt-x' + } } }; diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 17976c2e..00000000 --- a/tailwind.config.js +++ /dev/null @@ -1,135 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./src/**/*.{html,js,svelte,ts}'], - darkMode: 'class', // 'media' for OS preference, 'class' for manual toggle - theme: { - extend: { - colors: { - // You can customize your color palette here - primary: { - light: '#4CAF50', - dark: '#3a8c3f' - }, - foreground: { - light: 'var(--color-foreground-light)', - DEFAULT: 'var(--color-foreground)', - dark: 'var(--color-foreground-dark)' - }, - background: { - light: '#f5f5f5', - dark: '#1a1a1a' - }, - card: { - light: 'var(--color-card, #ffffff)', - dark: 'var(--color-card, #2d2d2d)' - }, - text: { - light: '#333333', - dark: '#e0e0e0' - }, - air: { - light: '#e6f7ff', - dark: '#0d3b66' - }, - soil: { - light: '#f6ffed', - dark: '#1e4620' - }, - hotpink: { - 50: '#fdf2f8', - 100: '#fce7f3', - 200: '#fbcfe8', - 300: '#f9a8d4', - 400: '#f472b6', // This will create --color-hotpink-400 - 500: '#ec4899', - 600: '#db2777', - 700: '#be185d', - 800: '#9d174d', - 900: '#831843' - } - }, - // Define spacing scale explicitly for Tailwind v4 - spacing: { - 0: '0px', - 0.5: '0.125rem', - 1: '0.25rem', - 1.5: '0.375rem', - 2: '0.5rem', - 2.5: '0.625rem', - 3: '0.75rem', - 3.5: '0.875rem', - 4: '1rem', - 5: '1.25rem', // Ensure gap-5 uses this value - 6: '1.5rem', - 7: '1.75rem', - 8: '2rem', - 9: '2.25rem', - 10: '2.5rem', - 11: '2.75rem', - 12: '3rem', - 14: '3.5rem', - 16: '4rem', - 20: '5rem', - 24: '6rem', - 28: '7rem', - 32: '8rem', - 36: '9rem', - 40: '10rem', - 44: '11rem', - 48: '12rem', - 52: '13rem', - 56: '14rem', - 60: '15rem', - 64: '16rem', - 72: '18rem', - 80: '20rem', - 96: '24rem' - } - } - }, - plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')], - // Explicitly enable core plugins needed - corePlugins: { - preflight: true, - // Layout - display: true, - flexDirection: true, - gridTemplateColumns: true, - // Spacing - padding: true, - margin: true, - gap: true, // Explicitly enable gap - // Sizing - width: true, - height: true, - minHeight: true, - // Typography - fontSize: true, - fontWeight: true, - lineHeight: true, - textAlign: true, - textColor: true, - // Backgrounds - backgroundColor: true, - // Borders - borderRadius: true, - borderWidth: true, - borderColor: true, - // Effects - boxShadow: true, - // Transitions - transitionProperty: true, - transitionDuration: true, - transitionTimingFunction: true, - // Interactivity - cursor: true, - appearance: true, - outlineStyle: true, - ringWidth: true, - ringColor: true, - // SVG - fill: true, - stroke: true, - strokeWidth: true - } -}; diff --git a/test-device-data-service.ts b/test-device-data-service.ts deleted file mode 100644 index 191c722f..00000000 --- a/test-device-data-service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DeviceDataService } from './src/lib/services/DeviceDataService'; - -// This is just a compilation test to ensure the new method signature works -function testMethod() { - // Test with all parameters - const service = new DeviceDataService({} as any); - - const result1 = service.getDeviceDataForReport({ - devEui: 'test', - startDate: new Date(), - endDate: new Date(), - timezone: 'UTC', - intervalMinutes: 30, - columns: ['temperature_c'], - ops: ['>'], - mins: [25.0], - maxs: [null] - }); - - // Test with default parameters - const result2 = service.getDeviceDataForReport({ - devEui: 'test', - startDate: new Date(), - endDate: new Date(), - timezone: 'UTC', - intervalMinutes: 30 - }); - - console.log('Method signatures compile correctly'); -} diff --git a/test-sw.html b/test-sw.html deleted file mode 100644 index 45cb1f74..00000000 --- a/test-sw.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - Service Worker Test - - -

    Service Worker Test

    -
    - - - - - - diff --git a/test-timezone-critical.sh b/test-timezone-critical.sh deleted file mode 100755 index 75f07ba7..00000000 --- a/test-timezone-critical.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash - -# Critical Timezone Conversion Test Runner -# This script runs the essential timezone tests to ensure the traffic data -# export functionality continues to work correctly. - -echo "🕐 Running Critical Timezone Conversion Tests..." -echo "================================================" -echo "" - -# Colors for output -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}ℹ️ These tests verify that the 'very very backwards' traffic pattern bug stays fixed${NC}" -echo -e "${BLUE}ℹ️ Key validation: August 1st midnight Tokyo → Record ID 35976${NC}" -echo "" - -# Run the core timezone conversion tests -echo -e "${YELLOW}1. Running Core Timezone Conversion Tests...${NC}" -pnpm vitest run src/lib/tests/DeviceDataService.timezone.test.ts --reporter=verbose - -TIMEZONE_EXIT_CODE=$? - -# Run the integration tests (database queries) -echo "" -echo -e "${YELLOW}2. Running Traffic Data Integration Tests...${NC}" -pnpm vitest run src/lib/tests/TrafficDataIntegration.test.ts --reporter=verbose - -INTEGRATION_EXIT_CODE=$? - -# Run the CSV logic tests (skip API tests that require auth) -echo "" -echo -e "${YELLOW}3. Running CSV Generation Logic Tests...${NC}" -pnpm vitest run src/lib/tests/CSVExportValidation.test.ts --testNamePattern="CSV Generation Logic Tests" --reporter=verbose - -CSV_EXIT_CODE=$? - -echo "" -echo "================================================" - -# Summary -if [ $TIMEZONE_EXIT_CODE -eq 0 ] && [ $INTEGRATION_EXIT_CODE -eq 0 ] && [ $CSV_EXIT_CODE -eq 0 ]; then - echo -e "${GREEN}✅ ALL CRITICAL TIMEZONE TESTS PASSED!${NC}" - echo -e "${GREEN}✅ Traffic data export timezone conversion is working correctly${NC}" - echo -e "${GREEN}✅ August 1st midnight Tokyo correctly converts to July 31st 3PM UTC${NC}" - echo -e "${GREEN}✅ No 'backwards' traffic patterns detected${NC}" - exit 0 -else - echo -e "${RED}❌ SOME CRITICAL TESTS FAILED!${NC}" - echo -e "${RED}❌ Please review the timezone conversion logic${NC}" - - if [ $TIMEZONE_EXIT_CODE -ne 0 ]; then - echo -e "${RED} - Core timezone conversion tests failed${NC}" - fi - - if [ $INTEGRATION_EXIT_CODE -ne 0 ]; then - echo -e "${RED} - Database integration tests failed${NC}" - fi - - if [ $CSV_EXIT_CODE -ne 0 ]; then - echo -e "${RED} - CSV generation tests failed${NC}" - fi - - exit 1 -fi \ No newline at end of file diff --git a/test-webhook-data.mjs b/test-webhook-data.mjs deleted file mode 100644 index e69de29b..00000000 diff --git a/tsconfig.json b/tsconfig.json index 4778bafa..2c2ed3c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { + "rewriteRelativeImportExtensions": true, "allowJs": true, "checkJs": true, "esModuleInterop": true, @@ -9,14 +10,11 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "types": ["svelte", "vite/client"] + "moduleResolution": "bundler" } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript } diff --git a/vite.config.ts b/vite.config.ts index 124f537d..e6136df6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,129 +1,34 @@ +import { paraglideVitePlugin } from '@inlang/paraglide-js'; +import devtoolsJson from 'vite-plugin-devtools-json'; import tailwindcss from '@tailwindcss/vite'; -import { svelteTesting } from '@testing-library/svelte/vite'; -import { SvelteKitPWA } from '@vite-pwa/sveltekit'; +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; -import commonjs from 'vite-plugin-commonjs'; export default defineConfig({ plugins: [ - commonjs({ - filter: (id) => id.includes('node_modules/canvas') - }), tailwindcss(), sveltekit(), - SvelteKitPWA({ - registerType: 'autoUpdate', - injectRegister: 'script-defer', - strategies: 'generateSW', - devOptions: { - enabled: true, - type: 'module' - }, - workbox: { - navigateFallback: '/offline.html', - navigateFallbackDenylist: [/^\/api\//, /^\/auth\/api\//, /^\/_app\//], - globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest}'], - runtimeCaching: [ - { - urlPattern: ({ request }) => request.destination === 'document', - handler: 'NetworkFirst', - options: { - cacheName: 'pages-cache', - networkTimeoutSeconds: 3 - } - } - ] - }, - manifest: { - name: 'CropWatch UI', - short_name: 'CW UI', - description: 'CropWatch User Interface', - start_url: '/', - id: '/', - display: 'standalone', - display_override: ['window-controls-overlay', 'standalone'], - background_color: '#ffffff', - theme_color: '#50C878', - orientation: 'portrait-primary', - handle_links: 'preferred', - iarc_rating_id: 'e1234567890', - dir: 'ltr', - prefer_related_applications: false, - scope_extensions: [ - { - origin: 'https://api.cropwatch.io' - } - ], - edge_side_panel: { - preferred_width: 300 - }, - protocol_handlers: [ - { - protocol: 'web+cropwatch', - url: '/handle-protocol?url=%s' - } - ], - file_handlers: [ - { - action: '/open-file', - accept: { - 'application/json': ['.json'] - } - } - ], - icons: [ - { - src: '/icons/icon-192x192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: '/icons/icon-512x512.png', - sizes: '512x512', - type: 'image/png' - } - ], - screenshots: [ - { - src: '/screenshots/screenshot-1280x720.png', - sizes: '1280x720', - type: 'image/png', - label: 'Login screen' - }, - { - src: '/screenshots/screenshot-dashboard-1280x720.png', - sizes: '1280x720', - type: 'image/png', - label: 'Dashboard' - } - ], - categories: [ - 'Smart Agriculture', - 'Cold Chain Management', - 'IoT Applications', - 'Data Analytics' - ], - launch_handler: { - client_mode: 'navigate-existing' // or 'focus-existing', 'auto' - } - } - }) + devtoolsJson(), + paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }) ], test: { - workspace: [ + expect: { requireAssertions: true }, + projects: [ { extends: './vite.config.ts', - plugins: [svelteTesting()], test: { name: 'client', - environment: 'jsdom', - clearMocks: true, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'], - setupFiles: ['./vitest-setup-client.ts'] + exclude: ['src/lib/server/**'] } }, + { extends: './vite.config.ts', test: { diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts deleted file mode 100644 index ccc8a70a..00000000 --- a/vitest-setup-client.ts +++ /dev/null @@ -1,18 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { vi } from 'vitest'; - -// required for svelte5 + jsdom as jsdom does not support matchMedia -Object.defineProperty(window, 'matchMedia', { - writable: true, - enumerable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn() - })) -}); - -// add more mocks here if you need them