From 2d5bdcbe351a0b71b2ed6f1584e4a3c309fd33b1 Mon Sep 17 00:00:00 2001 From: nathanb Date: Fri, 29 May 2026 18:51:28 +0100 Subject: [PATCH] Add UTC datetime helpers to attempt to fix running issue --- .aiignore | 23 + .gitignore | 7 + ESP_RFID_API.md | 463 +++ .../8d2b0c4a1f7e_add_esp_rfid_attendance.py | 130 + ...8e6_add_esp_provisioning_and_write_jobs.py | 81 + .../e7a9c2b1d4f0_add_pending_esp_api_key.py | 25 + backend/app/api/v1/esp.py | 860 +++++ backend/app/core/datetime.py | 30 + backend/app/services/attendance_service.py | 42 + backend/app/tests/test_datetime_utc.py | 47 + backend/app/tests/test_esp_provisioning.py | 186 + frontend/package-lock.json | 3411 +++++++++++++++++ .../src/components/EspReaderManagement.tsx | 1218 ++++++ .../components/dashboard/DashboardTopbar.tsx | 77 + frontend/src/components/layout/AppFooter.tsx | 14 + .../src/components/layout/CookieBanner.tsx | 18 + .../src/components/layout/PortalBrand.tsx | 19 + frontend/src/contexts/ConfirmContext.tsx | 108 + frontend/src/contexts/ToastContext.tsx | 65 + .../pages/dashboard/AdminWorkspacePage.tsx | 81 + .../pages/dashboard/MemberOverviewPage.tsx | 189 + .../pages/dashboard/MemberQuestionsPage.tsx | 21 + .../pages/dashboard/MemberSettingsPage.tsx | 165 + frontend/src/utils/profileQuestionLogic.ts | 6 +- frontend/src/utils/timezone.ts | 92 + 25 files changed, 7373 insertions(+), 5 deletions(-) create mode 100644 .aiignore create mode 100644 ESP_RFID_API.md create mode 100644 backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py create mode 100644 backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py create mode 100644 backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py create mode 100644 backend/app/api/v1/esp.py create mode 100644 backend/app/core/datetime.py create mode 100644 backend/app/services/attendance_service.py create mode 100644 backend/app/tests/test_datetime_utc.py create mode 100644 backend/app/tests/test_esp_provisioning.py create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/components/EspReaderManagement.tsx create mode 100644 frontend/src/components/dashboard/DashboardTopbar.tsx create mode 100644 frontend/src/components/layout/AppFooter.tsx create mode 100644 frontend/src/components/layout/CookieBanner.tsx create mode 100644 frontend/src/components/layout/PortalBrand.tsx create mode 100644 frontend/src/contexts/ConfirmContext.tsx create mode 100644 frontend/src/contexts/ToastContext.tsx create mode 100644 frontend/src/pages/dashboard/AdminWorkspacePage.tsx create mode 100644 frontend/src/pages/dashboard/MemberOverviewPage.tsx create mode 100644 frontend/src/pages/dashboard/MemberQuestionsPage.tsx create mode 100644 frontend/src/pages/dashboard/MemberSettingsPage.tsx create mode 100644 frontend/src/utils/timezone.ts diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..cb989a0 --- /dev/null +++ b/.aiignore @@ -0,0 +1,23 @@ +# An .aiignore file follows the same syntax as a .gitignore file. +# .gitignore documentation: https://git-scm.com/docs/gitignore + +# you can ignore files +.DS_Store +*.log +*.tmp + +# or folders +dist/ +build/ +out/ +.idea/ + +/.aiignore +.aiignore + +/.env +/.env.* +.env +.env.* +**/.env +**/.env.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index cd0bfab..3dd5fca 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,13 @@ wheels/ .installed.cfg *.egg +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + # Environment variables .env .env.local diff --git a/ESP_RFID_API.md b/ESP_RFID_API.md new file mode 100644 index 0000000..279d542 --- /dev/null +++ b/ESP_RFID_API.md @@ -0,0 +1,463 @@ +# ESP RFID Integration Contract + +The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs. + +All timestamps are UTC ISO-8601 unless otherwise noted. + +## 1. Reader Setup and Approval + +### Register from the ESP dashboard + +Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured. + +```http +POST /api/v1/esp/device/register +Content-Type: application/json +``` + +Request: + +```json +{ + "device_id": "front-desk-01", + "name": "Front Desk Reader", + "location": "Reception", + "reader_type": "checkin_checkout", + "can_write_cards": true, + "firmware_version": "esp32-rfid-0.1.0", + "notes": "PN532 over I2C" +} +``` + +Response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "pending", + "registration_token": "one-time-registration-token", + "message": "Registration received. Approve this reader in the admin panel.", + "poll_interval_seconds": 5 +} +``` + +Firmware must store: + +- `device_id` +- `registration_token` + +### Admin approval + +In the web portal: + +- Dashboard -> Admin -> ESP RFID -> Setup +- Approve or reject pending readers. + +When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below. + +### Poll provisioning status + +Use this while waiting for approval. + +```http +GET /api/v1/esp/device/provisioning-status +X-ESP-Device-ID: front-desk-01 +X-ESP-Registration-Token: one-time-registration-token +``` + +Pending response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "pending", + "message": "Waiting for admin approval.", + "api_key": null, + "poll_interval_seconds": 5 +} +``` + +Approved response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "provisioned", + "message": "Reader approved. Store this API key; it will not be returned again.", + "api_key": "generated-api-key", + "poll_interval_seconds": 5 +} +``` + +Firmware must store `api_key` permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader. + +Rejected response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "rejected", + "message": "Reader registration rejected.", + "api_key": null, + "poll_interval_seconds": 5 +} +``` + +## 2. Authenticated Reader Requests + +After provisioning, every reader endpoint uses: + +```http +X-ESP-Device-ID: front-desk-01 +X-ESP-API-Key: generated-api-key +Content-Type: application/json +``` + +Invalid, inactive, unapproved, or rejected readers receive `401`. + +## 3. Time Sync + +The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed. + +## 3. Local ESP Dashboard + +The firmware in `ESP/main.cpp` serves the local dashboard on port `80`. + +Access options: + +- If WiFi connects, open `http:///`. +- If WiFi does not connect, the reader starts a setup AP named `RFID-Setup-XXXXXX`; connect to it and open `http://192.168.4.1/`. + +Default local dashboard credentials are configured in `ESP/secrets.h`: + +- Username: `admin` +- Password: `admin` + +The dashboard shows: + +- WiFi status and RSSI +- Provisioning status +- Current operating mode +- Server-synced clock +- Last heartbeat result +- Current write-job state +- Last card UID/action/result + +The local UI also exposes controls for: + +- Saving server/WiFi/reader configuration +- Registering or re-registering the reader +- Manually syncing time +- Manually polling for queued write jobs +- Clearing provisioning +- Rebooting + +Local dashboard JSON endpoints: + +- `GET /api/status` +- `POST /api/config` +- `POST /api/register` +- `POST /api/sync-time` +- `POST /api/poll-job` +- `POST /api/clear-provisioning` +- `POST /api/cancel-local-job` +- `POST /api/reboot` + +These are local ESP endpoints, not SASA backend endpoints. + +## 4. Heartbeat + +The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard. + +```http +POST /api/v1/esp/device/heartbeat +X-ESP-Device-ID: front-desk-01 +X-ESP-API-Key: generated-api-key +Content-Type: application/json +``` + +Request: + +```json +{ + "mode": "idle", + "message": "Heartbeat OK", + "wifi_rssi": -54, + "free_heap": 184320, + "firmware_version": "esp32-rfid-0.2.0", + "active_write_job_id": null +} +``` + +Response: + +```json +{ + "ok": true, + "server_time_utc": "2026-05-05T10:15:30.123456", + "unix_ms": 1777976130123, + "heartbeat_interval_seconds": 10, + "time_poll_interval_seconds": 3, + "write_job_poll_interval_seconds": 3 +} +``` + +Firmware modes currently used: + +- `setup` +- `waiting approval` +- `idle` +- `syncing time` +- `heartbeat` +- `checking jobs` +- `ready to write` +- `writing card` +- `reporting tap` +- `error` + +The backend updates `last_seen_at` when heartbeat succeeds. + +## 5. Time Sync + +The reader should poll every 3 seconds. + +```http +GET /api/v1/esp/device/time +``` + +Response: + +```json +{ + "server_time_utc": "2026-05-05T10:15:30.123456", + "unix_ms": 1777976130123, + "poll_interval_seconds": 3 +} +``` + +Use `unix_ms` to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device. + +## 6. Check-In and Check-Out Taps + +```http +POST /api/v1/esp/device/taps +``` + +Request: + +```json +{ + "card_uid": "04A1B2C3D4", + "tapped_at": "2026-05-05T10:15:30.123456", + "reader_type": "checkin_checkout" +} +``` + +`tapped_at` is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence. + +Accepted check-in: + +```json +{ + "accepted": true, + "action": "check_in", + "message": "Checked in", + "server_time_utc": "2026-05-05T10:15:30.123456", + "tap_id": 123, + "session_id": 45, + "user_id": 7, + "user_name": "Jane Smith", + "checked_in_at": "2026-05-05T10:15:30.123456", + "checked_out_at": null, + "duration_seconds": null +} +``` + +Accepted check-out: + +```json +{ + "accepted": true, + "action": "check_out", + "message": "Checked out", + "server_time_utc": "2026-05-05T12:20:30.123456", + "tap_id": 124, + "session_id": 45, + "user_id": 7, + "user_name": "Jane Smith", + "checked_in_at": "2026-05-05T10:15:30.123456", + "checked_out_at": "2026-05-05T12:20:30.123456", + "duration_seconds": 7500 +} +``` + +Denied: + +```json +{ + "accepted": false, + "action": "denied", + "message": "Unknown RFID card", + "server_time_utc": "2026-05-05T10:15:30.123456", + "tap_id": 125, + "session_id": null, + "user_id": null, + "user_name": null +} +``` + +The server logs denied taps so unknown cards can still be audited. + +The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu. + +## 7. Queued RFID Card Writes + +Admins queue card writes in: + +- Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write + +They select: + +- User +- Writing reader +- Card label + +The reader does not need an inbound network port. It polls for jobs. + +### Poll next write job + +Only readers with `can_write_cards=true` may use this endpoint. + +```http +GET /api/v1/esp/device/write-jobs/next +``` + +No job response: + +```json +null +``` + +Job response: + +```json +{ + "id": 20, + "reader_id": 3, + "user_id": 7, + "card_id": null, + "label": "Jane main card", + "status": "claimed", + "requested_by_user_id": 1, + "card_uid": null, + "write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}", + "claimed_at": "2026-05-05T10:15:30.123456", + "completed_at": null, + "error_message": null, + "created_at": "2026-05-05T10:14:00.000000", + "updated_at": "2026-05-05T10:15:30.123456" +} +``` + +Firmware behavior: + +1. Poll for a job. +2. If a job is returned, enter `ready to write` mode and show the target user/label on the ESP dashboard. +3. On the next card tap, write `write_payload` to the card if supported by the chosen RFID card type. +4. Report success/failure. + +The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key `FF FF FF FF FF FF`, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs. + +### Complete write job + +```http +POST /api/v1/esp/device/write-jobs/20/complete +``` + +Success request: + +```json +{ + "success": true, + "card_uid": "04A1B2C3D4" +} +``` + +Failure request: + +```json +{ + "success": false, + "error_message": "Card write failed verification" +} +``` + +On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label. + +## 8. ESP Local Dashboard Login + +Use this when someone logs into the ESP-hosted dashboard. + +```http +POST /api/v1/esp/device/dashboard-login +``` + +Request: + +```json +{ + "email": "admin@example.com", + "password": "password" +} +``` + +Response: + +```json +{ + "valid": true, + "user_id": 1, + "role": "admin", + "user_name": "Admin User" +} +``` + +Only `admin` and `super_admin` users return `valid: true`. + +## 9. Admin API Summary + +These endpoints use the normal portal JWT. + +- `GET /api/v1/esp/admin/readers` +- `POST /api/v1/esp/admin/readers` manual reader/key creation fallback +- `PUT /api/v1/esp/admin/readers/{reader_id}` +- `POST /api/v1/esp/admin/readers/{reader_id}/approve` +- `POST /api/v1/esp/admin/readers/{reader_id}/reject` +- `DELETE /api/v1/esp/admin/readers/{reader_id}` +- `POST /api/v1/esp/device/heartbeat` +- `GET /api/v1/esp/admin/cards` +- `POST /api/v1/esp/admin/cards` +- `PUT /api/v1/esp/admin/cards/{card_id}` +- `GET /api/v1/esp/admin/write-jobs?limit=100` +- `POST /api/v1/esp/admin/write-jobs` +- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel` +- `GET /api/v1/esp/admin/taps?limit=100` +- `GET /api/v1/esp/admin/attendance?open_only=false&limit=100` +- `POST /api/v1/esp/admin/attendance/close-stale` + +Stale close request: + +```json +{ + "checkout_hour": 17 +} +``` + +The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks `checkout_source` as `system`. + +## 10. Time Contract Summary + +- API responses use UTC/Zulu datetimes. +- ESP readers sync against `/api/v1/esp/device/time`. +- Reader-supplied timestamps are normalized to UTC before storage. +- The browser frontend displays these values in Europe/London for operators and members. diff --git a/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py b/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py new file mode 100644 index 0000000..f56d599 --- /dev/null +++ b/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py @@ -0,0 +1,130 @@ +"""Add ESP RFID attendance tables + +Revision ID: 8d2b0c4a1f7e +Revises: 2e8a0f9d4b31 +Create Date: 2026-05-05 11:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "8d2b0c4a1f7e" +down_revision: Union[str, None] = "2e8a0f9d4b31" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "esp_readers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("device_id", sa.String(length=100), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("location", sa.String(length=255), nullable=True), + sa.Column("reader_type", sa.Enum("checkin_checkout", name="espreadertype"), nullable=False), + sa.Column("api_key_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_esp_readers_device_id"), "esp_readers", ["device_id"], unique=True) + op.create_index(op.f("ix_esp_readers_id"), "esp_readers", ["id"], unique=False) + + op.create_table( + "rfid_cards", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("uid", sa.String(length=100), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_cards_id"), "rfid_cards", ["id"], unique=False) + op.create_index(op.f("ix_rfid_cards_uid"), "rfid_cards", ["uid"], unique=True) + op.create_index(op.f("ix_rfid_cards_user_id"), "rfid_cards", ["user_id"], unique=False) + + op.create_table( + "rfid_taps", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("card_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("card_uid", sa.String(length=100), nullable=False), + sa.Column("action", sa.Enum("check_in", "check_out", "denied", "unknown", name="esptapaction"), nullable=False), + sa.Column("accepted", sa.Boolean(), nullable=False), + sa.Column("message", sa.String(length=255), nullable=True), + sa.Column("raw_payload", sa.Text(), nullable=True), + sa.Column("tapped_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_taps_card_id"), "rfid_taps", ["card_id"], unique=False) + op.create_index(op.f("ix_rfid_taps_card_uid"), "rfid_taps", ["card_uid"], unique=False) + op.create_index(op.f("ix_rfid_taps_id"), "rfid_taps", ["id"], unique=False) + op.create_index(op.f("ix_rfid_taps_reader_id"), "rfid_taps", ["reader_id"], unique=False) + op.create_index(op.f("ix_rfid_taps_tapped_at"), "rfid_taps", ["tapped_at"], unique=False) + op.create_index(op.f("ix_rfid_taps_user_id"), "rfid_taps", ["user_id"], unique=False) + + op.create_table( + "attendance_sessions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("check_in_tap_id", sa.Integer(), nullable=False), + sa.Column("check_out_tap_id", sa.Integer(), nullable=True), + sa.Column("checked_in_at", sa.DateTime(), nullable=False), + sa.Column("checked_out_at", sa.DateTime(), nullable=True), + sa.Column("checkout_source", sa.Enum("user", "system", name="attendancecheckoutsource"), nullable=True), + sa.Column("system_flag_reason", sa.String(length=255), nullable=True), + sa.Column("duration_seconds", sa.Integer(), nullable=True), + sa.Column("is_open", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["check_in_tap_id"], ["rfid_taps.id"]), + sa.ForeignKeyConstraint(["check_out_tap_id"], ["rfid_taps.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_attendance_sessions_checked_in_at"), "attendance_sessions", ["checked_in_at"], unique=False) + op.create_index(op.f("ix_attendance_sessions_checked_out_at"), "attendance_sessions", ["checked_out_at"], unique=False) + op.create_index(op.f("ix_attendance_sessions_id"), "attendance_sessions", ["id"], unique=False) + op.create_index(op.f("ix_attendance_sessions_is_open"), "attendance_sessions", ["is_open"], unique=False) + op.create_index(op.f("ix_attendance_sessions_reader_id"), "attendance_sessions", ["reader_id"], unique=False) + op.create_index(op.f("ix_attendance_sessions_user_id"), "attendance_sessions", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_attendance_sessions_user_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_reader_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_is_open"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_checked_out_at"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_checked_in_at"), table_name="attendance_sessions") + op.drop_table("attendance_sessions") + op.drop_index(op.f("ix_rfid_taps_user_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_tapped_at"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_reader_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_card_uid"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_card_id"), table_name="rfid_taps") + op.drop_table("rfid_taps") + op.drop_index(op.f("ix_rfid_cards_user_id"), table_name="rfid_cards") + op.drop_index(op.f("ix_rfid_cards_uid"), table_name="rfid_cards") + op.drop_index(op.f("ix_rfid_cards_id"), table_name="rfid_cards") + op.drop_table("rfid_cards") + op.drop_index(op.f("ix_esp_readers_id"), table_name="esp_readers") + op.drop_index(op.f("ix_esp_readers_device_id"), table_name="esp_readers") + op.drop_table("esp_readers") diff --git a/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py b/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py new file mode 100644 index 0000000..b07d7d8 --- /dev/null +++ b/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py @@ -0,0 +1,81 @@ +"""Add ESP provisioning and RFID write jobs + +Revision ID: c4f1d2a9b8e6 +Revises: 8d2b0c4a1f7e +Create Date: 2026-05-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c4f1d2a9b8e6" +down_revision: Union[str, None] = "8d2b0c4a1f7e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "esp_readers", + sa.Column( + "provisioning_status", + sa.Enum("pending", "approved", "provisioned", "rejected", name="espreaderprovisioningstatus"), + nullable=False, + server_default="provisioned", + ), + ) + op.add_column("esp_readers", sa.Column("registration_token_hash", sa.String(length=255), nullable=True)) + op.add_column("esp_readers", sa.Column("can_write_cards", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("esp_readers", sa.Column("firmware_version", sa.String(length=100), nullable=True)) + op.add_column("esp_readers", sa.Column("approved_at", sa.DateTime(), nullable=True)) + op.add_column("esp_readers", sa.Column("provisioned_at", sa.DateTime(), nullable=True)) + op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=True) + + op.create_table( + "rfid_card_write_jobs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("card_id", sa.Integer(), nullable=True), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("status", sa.Enum("pending", "claimed", "completed", "failed", "cancelled", name="rfidwritejobstatus"), nullable=False), + sa.Column("requested_by_user_id", sa.Integer(), nullable=False), + sa.Column("card_uid", sa.String(length=100), nullable=True), + sa.Column("write_payload", sa.Text(), nullable=True), + sa.Column("claimed_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("error_message", sa.String(length=500), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_card_write_jobs_card_id"), "rfid_card_write_jobs", ["card_id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_card_uid"), "rfid_card_write_jobs", ["card_uid"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_id"), "rfid_card_write_jobs", ["id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_reader_id"), "rfid_card_write_jobs", ["reader_id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_status"), "rfid_card_write_jobs", ["status"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_user_id"), "rfid_card_write_jobs", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_rfid_card_write_jobs_user_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_status"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_reader_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_card_uid"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_card_id"), table_name="rfid_card_write_jobs") + op.drop_table("rfid_card_write_jobs") + op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=False) + op.drop_column("esp_readers", "provisioned_at") + op.drop_column("esp_readers", "approved_at") + op.drop_column("esp_readers", "firmware_version") + op.drop_column("esp_readers", "can_write_cards") + op.drop_column("esp_readers", "registration_token_hash") + op.drop_column("esp_readers", "provisioning_status") diff --git a/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py b/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py new file mode 100644 index 0000000..560f326 --- /dev/null +++ b/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py @@ -0,0 +1,25 @@ +"""Add pending ESP API key delivery + +Revision ID: e7a9c2b1d4f0 +Revises: c4f1d2a9b8e6 +Create Date: 2026-05-05 13:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "e7a9c2b1d4f0" +down_revision: Union[str, None] = "c4f1d2a9b8e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("esp_readers", sa.Column("pending_api_key", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column("esp_readers", "pending_api_key") diff --git a/backend/app/api/v1/esp.py b/backend/app/api/v1/esp.py new file mode 100644 index 0000000..ee237d5 --- /dev/null +++ b/backend/app/api/v1/esp.py @@ -0,0 +1,860 @@ +import json +import secrets +from datetime import datetime, timedelta +from typing import List, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from ...api.dependencies import get_admin_user +from ...core.database import get_db +from ...core.datetime import to_utc_naive, to_zulu_iso, unix_ms_utc, utc_now +from ...core.security import ( + get_machine_token_hash, + is_machine_token_hash, + verify_machine_token, + verify_password, +) +from ...models.models import ( + AttendanceCheckoutSource, + AttendanceSession, + EspReader, + EspReaderProvisioningStatus, + EspTapAction, + RfidCard, + RfidCardWriteJob, + RfidTap, + RfidWriteJobStatus, + User, + UserRole, +) +from ...schemas import ( + AttendanceSessionResponse, + EspDashboardLoginResponse, + EspHeartbeatRequest, + EspHeartbeatResponse, + EspReaderCreate, + EspReaderCreateResponse, + EspReaderProvisioningResponse, + EspReaderRegistrationRequest, + EspReaderRegistrationResponse, + EspReaderResponse, + EspReaderUpdate, + EspTimeResponse, + LoginRequest, + MessageResponse, + RfidCardCreate, + RfidCardResponse, + RfidCardUpdate, + RfidTapAdminResponse, + RfidTapRequest, + RfidTapResponse, + RfidWriteJobCompleteRequest, + RfidWriteJobCreate, + RfidWriteJobResponse, + StaleSessionCloseRequest, + StaleSessionCloseResponse, +) +from ...services.attendance_service import close_stale_attendance_sessions, duration_seconds + +router = APIRouter() +READER_LAST_SEEN_WRITE_INTERVAL = timedelta(seconds=30) + + +def _normalize_card_uid(uid: str) -> str: + return uid.strip().upper().replace(" ", "") + + +def _new_api_key() -> str: + return secrets.token_urlsafe(32) + + +def _new_registration_token() -> str: + return secrets.token_urlsafe(24) + + +def _provisioning_status_value(value: object) -> str: + return getattr(value, "value", value) + + +def _compact_tap_response(tap: RfidTap) -> JSONResponse: + return JSONResponse( + content={ + "ok": tap.accepted, + "a": getattr(tap.action, "value", tap.action), + "m": tap.message or "", + } + ) + + +async def get_current_reader( + x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"), + x_esp_api_key: str = Header(..., alias="X-ESP-API-Key"), + db: Session = Depends(get_db), +) -> EspReader: + reader = db.query(EspReader).filter(EspReader.device_id == x_esp_device_id).first() + provisioning_status = _provisioning_status_value(reader.provisioning_status) if reader else None + credentials_valid = bool( + reader + and reader.is_active + and reader.api_key_hash + and provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] + and verify_machine_token(x_esp_api_key, reader.api_key_hash) + ) + if ( + not credentials_valid + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid ESP reader credentials", + ) + + now = utc_now() + should_persist_reader = False + if not is_machine_token_hash(reader.api_key_hash): + reader.api_key_hash = get_machine_token_hash(x_esp_api_key) + should_persist_reader = True + if ( + reader.last_seen_at is None + or now - reader.last_seen_at >= READER_LAST_SEEN_WRITE_INTERVAL + ): + reader.last_seen_at = now + should_persist_reader = True + if reader.pending_api_key: + reader.pending_api_key = None + should_persist_reader = True + if should_persist_reader: + db.commit() + return reader + + +def _get_reader_by_registration_token( + device_id: str, + registration_token: str, + db: Session, +) -> EspReader: + reader = db.query(EspReader).filter(EspReader.device_id == device_id).first() + credentials_valid = bool( + reader + and reader.registration_token_hash + and verify_machine_token(registration_token, reader.registration_token_hash) + ) + if not credentials_valid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid reader registration token", + ) + if not is_machine_token_hash(reader.registration_token_hash): + reader.registration_token_hash = get_machine_token_hash(registration_token) + db.commit() + return reader + + +@router.post("/device/register", response_model=EspReaderRegistrationResponse, status_code=status.HTTP_202_ACCEPTED) +async def register_reader( + registration: EspReaderRegistrationRequest, + db: Session = Depends(get_db), +): + existing = db.query(EspReader).filter(EspReader.device_id == registration.device_id).first() + existing_status = _provisioning_status_value(existing.provisioning_status) if existing else None + allow_recovery = bool( + existing + and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] + and existing.last_seen_at is None + ) + if existing and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] and not allow_recovery: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Reader is already approved. Use its existing API key or rotate it from admin.", + ) + + registration_token = _new_registration_token() + now = utc_now() + if existing: + existing.name = registration.name + existing.location = registration.location + existing.reader_type = registration.reader_type + existing.can_write_cards = registration.can_write_cards + existing.firmware_version = registration.firmware_version + existing.notes = registration.notes + existing.registration_token_hash = get_machine_token_hash(registration_token) + existing.provisioning_status = ( + EspReaderProvisioningStatus.APPROVED + if allow_recovery + else EspReaderProvisioningStatus.PENDING + ) + existing.is_active = True + if not allow_recovery: + existing.pending_api_key = None + existing.updated_at = now + reader = existing + else: + reader = EspReader( + device_id=registration.device_id, + name=registration.name, + location=registration.location, + reader_type=registration.reader_type, + can_write_cards=registration.can_write_cards, + firmware_version=registration.firmware_version, + notes=registration.notes, + registration_token_hash=get_machine_token_hash(registration_token), + provisioning_status=EspReaderProvisioningStatus.PENDING, + is_active=True, + ) + db.add(reader) + + db.commit() + return { + "device_id": reader.device_id, + "provisioning_status": reader.provisioning_status, + "registration_token": registration_token, + "message": ( + "Reader recovery accepted. Poll provisioning to receive the API key again." + if allow_recovery + else "Registration received. Approve this reader in the admin panel." + ), + "poll_interval_seconds": 5, + } + + +@router.get("/device/provisioning-status", response_model=EspReaderProvisioningResponse) +async def get_provisioning_status( + x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"), + x_esp_registration_token: str = Header(..., alias="X-ESP-Registration-Token"), + db: Session = Depends(get_db), +): + reader = _get_reader_by_registration_token(x_esp_device_id, x_esp_registration_token, db) + provisioning_status = _provisioning_status_value(reader.provisioning_status) + if provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]: + api_key = reader.pending_api_key or _new_api_key() + now = utc_now() + if not reader.pending_api_key: + reader.api_key_hash = get_machine_token_hash(api_key) + reader.pending_api_key = api_key + reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED + reader.provisioned_at = now + reader.updated_at = now + db.commit() + payload = { + "device_id": reader.device_id, + "provisioning_status": EspReaderProvisioningStatus.PROVISIONED.value, + "message": "Reader approved. Store this API key; it will not be returned again.", + "api_key": api_key, + "apiKey": api_key, + "poll_interval_seconds": 5, + } + print( + f"[ESP] provisioning-status device={reader.device_id} status=provisioned has_api_key={bool(api_key)}" + ) + return JSONResponse(content=payload) + + messages = { + EspReaderProvisioningStatus.PENDING.value: "Waiting for admin approval.", + EspReaderProvisioningStatus.APPROVED.value: "Reader approved. API key is already available or will be generated shortly.", + EspReaderProvisioningStatus.PROVISIONED.value: "Reader already provisioned. Use the stored API key.", + EspReaderProvisioningStatus.REJECTED.value: "Reader registration rejected.", + } + payload = { + "device_id": reader.device_id, + "provisioning_status": provisioning_status, + "message": messages.get(provisioning_status, "Waiting for admin approval."), + "api_key": None, + "apiKey": None, + "poll_interval_seconds": 5, + } + print( + f"[ESP] provisioning-status device={reader.device_id} status={provisioning_status} has_api_key=False" + ) + return JSONResponse(content=payload) + + +@router.get("/device/time", response_model=EspTimeResponse) +async def get_device_time( + reader: EspReader = Depends(get_current_reader), +): + now = utc_now() + return { + "server_time_utc": now, + "unix_ms": unix_ms_utc(now), + "poll_interval_seconds": 3, + } + + +@router.post("/device/heartbeat", response_model=EspHeartbeatResponse) +async def record_heartbeat( + heartbeat: EspHeartbeatRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + now = utc_now() + if heartbeat.firmware_version and heartbeat.firmware_version != reader.firmware_version: + reader.firmware_version = heartbeat.firmware_version + reader.last_seen_at = now + reader.updated_at = now + db.commit() + return { + "ok": True, + "server_time_utc": now, + "unix_ms": unix_ms_utc(now), + "heartbeat_interval_seconds": 10, + "time_poll_interval_seconds": 3, + "write_job_poll_interval_seconds": 3, + } + + +@router.post("/device/taps", response_model=RfidTapResponse) +async def record_tap( + tap_request: RfidTapRequest, + x_esp_compact_response: str | None = Header(None, alias="X-ESP-Compact-Response"), + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + compact_response = x_esp_compact_response == "1" + now = utc_now() + tapped_at = to_utc_naive(tap_request.tapped_at) or now + card_uid = _normalize_card_uid(tap_request.card_uid) + card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first() + + tap = RfidTap( + reader_id=reader.id, + card_id=card.id if card else None, + user_id=card.user_id if card and card.is_active else None, + card_uid=card_uid, + action=EspTapAction.UNKNOWN, + accepted=False, + raw_payload=json.dumps(tap_request.model_dump(mode="json")), + tapped_at=tapped_at, + ) + db.add(tap) + db.flush() + + if not card: + tap.action = EspTapAction.DENIED + tap.message = "Unknown RFID card" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + } + + if not card.is_active or not card.user_id: + tap.action = EspTapAction.DENIED + tap.message = "RFID card is inactive or unassigned" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + } + + user = db.query(User).filter(User.id == card.user_id).first() + if not user or not user.is_active: + tap.action = EspTapAction.DENIED + tap.message = "User is inactive" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "user_id": card.user_id, + } + + open_session = ( + db.query(AttendanceSession) + .filter( + AttendanceSession.user_id == user.id, + AttendanceSession.is_open == True, + ) + .order_by(AttendanceSession.checked_in_at.desc()) + .first() + ) + + user_name = f"{user.first_name} {user.last_name}" + if open_session: + tap.action = EspTapAction.CHECK_OUT + tap.accepted = True + tap.message = "Checked out" + open_session.check_out_tap_id = tap.id + open_session.checked_out_at = tapped_at + open_session.checkout_source = AttendanceCheckoutSource.USER + open_session.duration_seconds = duration_seconds(open_session.checked_in_at, tapped_at) + open_session.is_open = False + open_session.updated_at = now + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": True, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "session_id": open_session.id, + "user_id": user.id, + "user_name": user_name, + "checked_in_at": open_session.checked_in_at, + "checked_out_at": open_session.checked_out_at, + "duration_seconds": open_session.duration_seconds, + } + + tap.action = EspTapAction.CHECK_IN + tap.accepted = True + tap.message = "Checked in" + session = AttendanceSession( + user_id=user.id, + reader_id=reader.id, + check_in_tap_id=tap.id, + checked_in_at=tapped_at, + is_open=True, + ) + db.add(session) + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": True, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "session_id": session.id, + "user_id": user.id, + "user_name": user_name, + "checked_in_at": session.checked_in_at, + } + + +@router.post("/device/dashboard-login", response_model=EspDashboardLoginResponse) +async def validate_dashboard_login( + login_data: LoginRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + user = db.query(User).filter(User.email == login_data.email).first() + if ( + not user + or not user.is_active + or user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN] + or not verify_password(login_data.password, user.hashed_password) + ): + return {"valid": False} + + return { + "valid": True, + "user_id": user.id, + "role": user.role, + "user_name": f"{user.first_name} {user.last_name}", + } + + +@router.get("/device/write-jobs/next", response_model=RfidWriteJobResponse | None) +async def get_next_write_job( + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + if not reader.can_write_cards: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reader is not enabled for card writing") + + job = ( + db.query(RfidCardWriteJob) + .filter( + RfidCardWriteJob.reader_id == reader.id, + RfidCardWriteJob.status == RfidWriteJobStatus.PENDING, + ) + .order_by(RfidCardWriteJob.created_at.asc()) + .first() + ) + if not job: + return None + + user = db.query(User).filter(User.id == job.user_id).first() + job.status = RfidWriteJobStatus.CLAIMED + job.claimed_at = utc_now() + job.write_payload = json.dumps( + { + "job_id": job.id, + "user_id": job.user_id, + "user_name": f"{user.first_name} {user.last_name}" if user else None, + "label": job.label, + "issued_at": to_zulu_iso(job.claimed_at), + } + ) + db.commit() + db.refresh(job) + return job + + +@router.post("/device/write-jobs/{job_id}/complete", response_model=RfidWriteJobResponse) +async def complete_write_job( + job_id: int, + completion: RfidWriteJobCompleteRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + job = ( + db.query(RfidCardWriteJob) + .filter(RfidCardWriteJob.id == job_id, RfidCardWriteJob.reader_id == reader.id) + .first() + ) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found") + if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Write job is already closed") + + now = utc_now() + if not completion.success: + job.status = RfidWriteJobStatus.FAILED + job.error_message = completion.error_message or "Reader reported write failure" + job.completed_at = now + job.updated_at = now + db.commit() + db.refresh(job) + return job + + if not completion.card_uid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="card_uid is required when success is true") + + card_uid = _normalize_card_uid(completion.card_uid) + card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first() + if card: + card.user_id = job.user_id + card.label = job.label + card.is_active = True + card.updated_at = now + else: + card = RfidCard(uid=card_uid, user_id=job.user_id, label=job.label, is_active=True) + db.add(card) + db.flush() + + job.card_id = card.id + job.card_uid = card_uid + job.status = RfidWriteJobStatus.COMPLETED + job.completed_at = now + job.updated_at = now + db.commit() + db.refresh(job) + return job + + +@router.get("/admin/readers", response_model=List[EspReaderResponse]) +async def list_readers( + include_inactive: bool = Query(True), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(EspReader) + if not include_inactive: + query = query.filter(EspReader.is_active == True) + return query.order_by(EspReader.name.asc()).all() + + +@router.post("/admin/readers", response_model=EspReaderCreateResponse, status_code=status.HTTP_201_CREATED) +async def create_reader( + reader_data: EspReaderCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + existing = db.query(EspReader).filter(EspReader.device_id == reader_data.device_id).first() + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Device ID already exists") + + api_key = reader_data.api_key or _new_api_key() + reader = EspReader( + device_id=reader_data.device_id, + name=reader_data.name, + location=reader_data.location, + reader_type=reader_data.reader_type, + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + api_key_hash=get_machine_token_hash(api_key), + is_active=reader_data.is_active, + can_write_cards=reader_data.can_write_cards, + firmware_version=reader_data.firmware_version, + approved_at=utc_now(), + provisioned_at=utc_now(), + notes=reader_data.notes, + ) + db.add(reader) + db.commit() + db.refresh(reader) + + return EspReaderCreateResponse( + **EspReaderResponse.model_validate(reader).model_dump(), + api_key=api_key, + ) + + +@router.post("/admin/readers/{reader_id}/approve", response_model=EspReaderResponse) +async def approve_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + if reader.provisioning_status == EspReaderProvisioningStatus.REJECTED: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejected reader must register again") + + now = utc_now() + reader.provisioning_status = EspReaderProvisioningStatus.APPROVED + reader.is_active = True + reader.approved_at = now + reader.updated_at = now + reader.api_key_hash = None + reader.pending_api_key = None + db.commit() + db.refresh(reader) + return reader + + +@router.post("/admin/readers/{reader_id}/reject", response_model=EspReaderResponse) +async def reject_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + reader.provisioning_status = EspReaderProvisioningStatus.REJECTED + reader.is_active = False + reader.pending_api_key = None + reader.updated_at = utc_now() + db.commit() + db.refresh(reader) + return reader + + +@router.put("/admin/readers/{reader_id}", response_model=EspReaderCreateResponse | EspReaderResponse) +async def update_reader( + reader_id: int, + reader_data: EspReaderUpdate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + + update_data = reader_data.model_dump(exclude_unset=True) + rotate_api_key = update_data.pop("rotate_api_key", False) + for field, value in update_data.items(): + setattr(reader, field, value) + + new_api_key: Optional[str] = None + if rotate_api_key: + new_api_key = _new_api_key() + reader.api_key_hash = get_machine_token_hash(new_api_key) + reader.pending_api_key = None + reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED + reader.provisioned_at = utc_now() + + reader.updated_at = utc_now() + db.commit() + db.refresh(reader) + + if new_api_key: + return EspReaderCreateResponse( + **EspReaderResponse.model_validate(reader).model_dump(), + api_key=new_api_key, + ) + return reader + + +@router.get("/admin/write-jobs", response_model=List[RfidWriteJobResponse]) +async def list_write_jobs( + limit: int = Query(100, ge=1, le=500), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + return db.query(RfidCardWriteJob).order_by(RfidCardWriteJob.created_at.desc()).limit(limit).all() + + +@router.post("/admin/write-jobs", response_model=RfidWriteJobResponse, status_code=status.HTTP_201_CREATED) +async def queue_write_job( + job_data: RfidWriteJobCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == job_data.reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader does not exist") + provisioning_status = _provisioning_status_value(reader.provisioning_status) + if not reader.is_active or provisioning_status not in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not active and provisioned") + if not reader.can_write_cards: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not enabled for card writing") + + user = db.query(User).filter(User.id == job_data.user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + job = RfidCardWriteJob( + reader_id=job_data.reader_id, + user_id=job_data.user_id, + label=job_data.label, + status=RfidWriteJobStatus.PENDING, + requested_by_user_id=admin_user.id, + ) + db.add(job) + db.commit() + db.refresh(job) + return job + + +@router.post("/admin/write-jobs/{job_id}/cancel", response_model=RfidWriteJobResponse) +async def cancel_write_job( + job_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + job = db.query(RfidCardWriteJob).filter(RfidCardWriteJob.id == job_id).first() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found") + if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only pending or claimed jobs can be cancelled") + job.status = RfidWriteJobStatus.CANCELLED + job.updated_at = utc_now() + db.commit() + db.refresh(job) + return job + + +@router.get("/admin/cards", response_model=List[RfidCardResponse]) +async def list_cards( + include_inactive: bool = Query(True), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(RfidCard) + if not include_inactive: + query = query.filter(RfidCard.is_active == True) + return query.order_by(RfidCard.uid.asc()).all() + + +@router.post("/admin/cards", response_model=RfidCardResponse, status_code=status.HTTP_201_CREATED) +async def create_card( + card_data: RfidCardCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + uid = _normalize_card_uid(card_data.uid) + existing = db.query(RfidCard).filter(RfidCard.uid == uid).first() + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="RFID card already exists") + + if card_data.user_id: + user = db.query(User).filter(User.id == card_data.user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + card = RfidCard( + uid=uid, + user_id=card_data.user_id, + label=card_data.label, + is_active=card_data.is_active, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +@router.put("/admin/cards/{card_id}", response_model=RfidCardResponse) +async def update_card( + card_id: int, + card_data: RfidCardUpdate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + card = db.query(RfidCard).filter(RfidCard.id == card_id).first() + if not card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID card not found") + + update_data = card_data.model_dump(exclude_unset=True) + if update_data.get("user_id"): + user = db.query(User).filter(User.id == update_data["user_id"]).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + for field, value in update_data.items(): + setattr(card, field, value) + card.updated_at = utc_now() + db.commit() + db.refresh(card) + return card + + +@router.get("/admin/taps", response_model=List[RfidTapAdminResponse]) +async def list_taps( + limit: int = Query(100, ge=1, le=500), + reader_id: Optional[int] = None, + user_id: Optional[int] = None, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(RfidTap) + if reader_id: + query = query.filter(RfidTap.reader_id == reader_id) + if user_id: + query = query.filter(RfidTap.user_id == user_id) + return query.order_by(RfidTap.tapped_at.desc()).limit(limit).all() + + +@router.get("/admin/attendance", response_model=List[AttendanceSessionResponse]) +async def list_attendance_sessions( + open_only: bool = False, + limit: int = Query(100, ge=1, le=500), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(AttendanceSession) + if open_only: + query = query.filter(AttendanceSession.is_open == True) + return query.order_by(AttendanceSession.checked_in_at.desc()).limit(limit).all() + + +@router.post("/admin/attendance/close-stale", response_model=StaleSessionCloseResponse) +async def close_stale_sessions( + request: StaleSessionCloseRequest, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + closed_count = close_stale_attendance_sessions( + db, + cutoff_date=request.cutoff_date, + checkout_hour=request.checkout_hour, + ) + return {"closed_count": closed_count} + + +@router.delete("/admin/readers/{reader_id}", response_model=MessageResponse) +async def delete_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + + db.query(AttendanceSession).filter(AttendanceSession.reader_id == reader.id).delete(synchronize_session=False) + db.query(RfidTap).filter(RfidTap.reader_id == reader.id).delete(synchronize_session=False) + db.query(RfidCardWriteJob).filter(RfidCardWriteJob.reader_id == reader.id).delete(synchronize_session=False) + db.delete(reader) + db.commit() + return {"message": "Reader deleted"} diff --git a/backend/app/core/datetime.py b/backend/app/core/datetime.py new file mode 100644 index 0000000..ea15fa5 --- /dev/null +++ b/backend/app/core/datetime.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone + + +def utc_now() -> datetime: + """Naive UTC datetime for existing SQLAlchemy DateTime columns.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def to_utc_naive(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + +def to_utc_aware(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def to_zulu_iso(value: datetime) -> str: + return to_utc_aware(value).isoformat().replace("+00:00", "Z") + + +def unix_ms_utc(value: datetime) -> int: + return int(to_utc_aware(value).timestamp() * 1000) diff --git a/backend/app/services/attendance_service.py b/backend/app/services/attendance_service.py new file mode 100644 index 0000000..29cd748 --- /dev/null +++ b/backend/app/services/attendance_service.py @@ -0,0 +1,42 @@ +from datetime import date, datetime, time, timedelta + +from sqlalchemy.orm import Session + +from ..core.datetime import utc_now +from ..models.models import AttendanceCheckoutSource, AttendanceSession + + +def duration_seconds(start: datetime, end: datetime) -> int: + return max(0, int((end - start).total_seconds())) + + +def close_stale_attendance_sessions( + db: Session, + cutoff_date: date | None = None, + checkout_hour: int = 17, +) -> int: + cutoff = cutoff_date or date.today() + cutoff_at = datetime.combine(cutoff, time.min) + sessions = ( + db.query(AttendanceSession) + .filter( + AttendanceSession.is_open == True, + AttendanceSession.checked_in_at < cutoff_at, + ) + .all() + ) + + now = utc_now() + for session in sessions: + checkout_at = datetime.combine(session.checked_in_at.date(), time(hour=checkout_hour)) + if checkout_at < session.checked_in_at: + checkout_at = session.checked_in_at + timedelta(minutes=1) + session.checked_out_at = checkout_at + session.checkout_source = AttendanceCheckoutSource.SYSTEM + session.system_flag_reason = "User did not check out before midnight; checkout time was system-set." + session.duration_seconds = duration_seconds(session.checked_in_at, checkout_at) + session.is_open = False + session.updated_at = now + + db.commit() + return len(sessions) diff --git a/backend/app/tests/test_datetime_utc.py b/backend/app/tests/test_datetime_utc.py new file mode 100644 index 0000000..d80eed3 --- /dev/null +++ b/backend/app/tests/test_datetime_utc.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from app.core.datetime import unix_ms_utc, utc_now +from app.schemas import EventCreate, EventResponse, EspTimeResponse + + +def test_event_input_datetime_is_normalized_to_utc_naive() -> None: + event = EventCreate( + title="Evening briefing", + event_date="2026-06-01T19:30:00+01:00", + event_time=None, + ) + + assert event.event_date == datetime(2026, 6, 1, 18, 30) + assert event.event_date.tzinfo is None + + +def test_response_datetimes_serialize_as_zulu() -> None: + event = EventResponse( + id=1, + title="Evening briefing", + description=None, + event_date=datetime(2026, 6, 1, 18, 30), + event_time=None, + location=None, + max_attendees=None, + status="draft", + created_by=1, + created_at=datetime(2026, 5, 1, 10, 0), + updated_at=datetime(2026, 5, 1, 10, 0), + ) + + payload = event.model_dump_json() + + assert '"event_date":"2026-06-01T18:30:00Z"' in payload + assert '"created_at":"2026-05-01T10:00:00Z"' in payload + + +def test_esp_time_uses_same_utc_instant_for_iso_and_unix_ms() -> None: + now = utc_now() + response = EspTimeResponse( + server_time_utc=now, + unix_ms=unix_ms_utc(now), + ) + + assert '"server_time_utc":"' in response.model_dump_json() + assert response.unix_ms == unix_ms_utc(now) diff --git a/backend/app/tests/test_esp_provisioning.py b/backend/app/tests/test_esp_provisioning.py new file mode 100644 index 0000000..b01f90e --- /dev/null +++ b/backend/app/tests/test_esp_provisioning.py @@ -0,0 +1,186 @@ +import asyncio +import json +from types import SimpleNamespace + +from app.api.v1 import esp +from app.core.security import get_machine_token_hash, get_password_hash, verify_machine_token +from app.models.models import EspReaderProvisioningStatus +from app.schemas import EspReaderRegistrationRequest + + +class _FakeDb: + def __init__(self, reader=None) -> None: + self.reader = reader + + class _Query: + def __init__(self, reader) -> None: + self.reader = reader + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.reader + + def commit(self) -> None: + return None + + def add(self, _obj) -> None: + return None + + def refresh(self, _obj) -> None: + return None + + def query(self, _model): + return self._Query(self.reader) + + +def test_provisioning_status_returns_api_key_for_enum_status(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status=EspReaderProvisioningStatus.APPROVED, + pending_api_key=None, + api_key_hash=None, + provisioned_at=None, + updated_at=None, + ) + + monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader) + monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key") + + response = asyncio.run( + esp.get_provisioning_status( + x_esp_device_id="esp32-123456", + x_esp_registration_token="token", + db=_FakeDb(), + ) + ) + payload = json.loads(response.body) + + assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value + assert payload["api_key"] == "generated-api-key" + assert payload["apiKey"] == "generated-api-key" + + +def test_register_reader_allows_recovery_before_first_authenticated_call(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + name="Old Reader", + location="Old Location", + reader_type="checkin_checkout", + can_write_cards=False, + firmware_version="old-fw", + notes="old", + registration_token_hash="old-hash", + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + is_active=True, + pending_api_key="pending-api-key", + last_seen_at=None, + updated_at=None, + ) + db = _FakeDb(reader) + + monkeypatch.setattr(esp, "_new_registration_token", lambda: "replacement-token") + monkeypatch.setattr(esp, "get_machine_token_hash", lambda value: f"hashed:{value}") + + response = asyncio.run( + esp.register_reader( + EspReaderRegistrationRequest( + device_id="esp32-123456", + name="Recovered Reader", + location="Front Desk", + reader_type="checkin_checkout", + can_write_cards=True, + firmware_version="new-fw", + notes="recovered", + ), + db=db, + ) + ) + + assert response["provisioning_status"] == EspReaderProvisioningStatus.APPROVED + assert response["registration_token"] == "replacement-token" + assert response["message"] == "Reader recovery accepted. Poll provisioning to receive the API key again." + assert reader.registration_token_hash == "hashed:replacement-token" + assert reader.pending_api_key == "pending-api-key" + assert reader.provisioning_status == EspReaderProvisioningStatus.APPROVED + + +def test_machine_token_hash_round_trip() -> None: + token = "esp-device-token" + stored_hash = get_machine_token_hash(token) + + assert verify_machine_token(token, stored_hash) is True + assert verify_machine_token("wrong-token", stored_hash) is False + + +def test_machine_token_verify_supports_legacy_bcrypt_hash() -> None: + token = "legacy-esp-token" + stored_hash = get_password_hash(token) + + assert verify_machine_token(token, stored_hash) is True + assert verify_machine_token("wrong-token", stored_hash) is False + + +def test_get_current_reader_migrates_legacy_bcrypt_api_key() -> None: + api_key = "legacy-api-key" + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + is_active=True, + api_key_hash=get_password_hash(api_key), + pending_api_key=None, + last_seen_at=None, + ) + db = _FakeDb(reader) + + response_reader = asyncio.run( + esp.get_current_reader( + x_esp_device_id="esp32-123456", + x_esp_api_key=api_key, + db=db, + ) + ) + + assert response_reader is reader + assert reader.api_key_hash == get_machine_token_hash(api_key) + + +def test_compact_tap_response_uses_short_keys() -> None: + tap = SimpleNamespace( + accepted=True, + action=SimpleNamespace(value="check_in"), + message="Checked in", + ) + + response = esp._compact_tap_response(tap) + payload = json.loads(response.body) + + assert payload == {"ok": True, "a": "check_in", "m": "Checked in"} + + +def test_provisioning_status_returns_api_key_for_string_status(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status="approved", + pending_api_key=None, + api_key_hash=None, + provisioned_at=None, + updated_at=None, + ) + + monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader) + monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key") + + response = asyncio.run( + esp.get_provisioning_status( + x_esp_device_id="esp32-123456", + x_esp_registration_token="token", + db=_FakeDb(), + ) + ) + payload = json.loads(response.body) + + assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value + assert payload["api_key"] == "generated-api-key" + assert payload["apiKey"] == "generated-api-key" diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e12dbf5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3411 @@ +{ + "name": "membership-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "membership-frontend", + "version": "1.0.0", + "dependencies": { + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "autoprefixer": "^10.4.16", + "axios": "^1.6.2", + "postcss": "^8.4.32", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.5", + "vitest": "^1.6.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.352", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", + "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/src/components/EspReaderManagement.tsx b/frontend/src/components/EspReaderManagement.tsx new file mode 100644 index 0000000..0f0b7b2 --- /dev/null +++ b/frontend/src/components/EspReaderManagement.tsx @@ -0,0 +1,1218 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + AttendanceSession, + EspReader, + RfidCard, + RfidTap, + RfidWriteJob, + User, + espService, + userService +} from '../services/membershipService'; +import { useConfirm } from '../contexts/ConfirmContext'; +import { formatLondonDateTime, utcMillis } from '../utils/timezone'; + +type EspAdminView = 'readers' | 'cards' | 'writing' | 'activity'; +type ReaderSortKey = 'reader' | 'status' | 'capability' | 'lastSeen'; +type CardSortKey = 'uid' | 'user' | 'label' | 'status' | 'updated'; +type WriteSortKey = 'created' | 'label' | 'user' | 'reader' | 'status'; +type SessionSortKey = 'member' | 'reader' | 'checkedIn' | 'checkedOut' | 'duration' | 'status'; +type TapSortKey = 'time' | 'reader' | 'card' | 'member' | 'action'; + +interface EspReaderManagementProps { + isActive?: boolean; + view: EspAdminView; + searchTerm?: string; + statusFilter?: string; + openWriteToken?: number; + refreshToken?: number; +} + +const pageSize = 25; + +const EspReaderManagement: React.FC = ({ + isActive = true, + view, + searchTerm = '', + statusFilter = 'all', + openWriteToken = 0, + refreshToken = 0 +}) => { + const { confirm } = useConfirm(); + const [readers, setReaders] = useState([]); + const [cards, setCards] = useState([]); + const [writeJobs, setWriteJobs] = useState([]); + const [taps, setTaps] = useState([]); + const [sessions, setSessions] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [busyAction, setBusyAction] = useState(null); + const [notice, setNotice] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + const [latestApiKey, setLatestApiKey] = useState<{ deviceId: string; apiKey: string } | null>(null); + const [editingReader, setEditingReader] = useState(null); + const [showWriteDrawer, setShowWriteDrawer] = useState(false); + const [readerEditForm, setReaderEditForm] = useState({ + name: '', + location: '', + notes: '', + can_write_cards: false, + is_active: true + }); + const [writeForm, setWriteForm] = useState({ reader_id: '', user_id: '', label: '' }); + const [readerPage, setReaderPage] = useState(1); + const [cardPage, setCardPage] = useState(1); + const [jobPage, setJobPage] = useState(1); + const [sessionPage, setSessionPage] = useState(1); + const [tapPage, setTapPage] = useState(1); + const [readerSortKey, setReaderSortKey] = useState('status'); + const [readerSortDirection, setReaderSortDirection] = useState<'asc' | 'desc'>('asc'); + const [cardSortKey, setCardSortKey] = useState('updated'); + const [cardSortDirection, setCardSortDirection] = useState<'asc' | 'desc'>('desc'); + const [writeSortKey, setWriteSortKey] = useState('created'); + const [writeSortDirection, setWriteSortDirection] = useState<'asc' | 'desc'>('desc'); + const [sessionSortKey, setSessionSortKey] = useState('checkedIn'); + const [sessionSortDirection, setSessionSortDirection] = useState<'asc' | 'desc'>('desc'); + const [tapSortKey, setTapSortKey] = useState('time'); + const [tapSortDirection, setTapSortDirection] = useState<'asc' | 'desc'>('desc'); + + useEffect(() => { + void loadData(); + }, []); + + useEffect(() => { + if (!isActive) return; + const intervalId = window.setInterval(() => { + if (document.visibilityState === 'visible' && !busyAction && !editingReader && !showWriteDrawer) { + void loadData({ silent: true }); + } + }, 5000); + return () => window.clearInterval(intervalId); + }, [busyAction, editingReader, isActive, showWriteDrawer]); + + useEffect(() => { + if (view === 'writing' && openWriteToken > 0) { + setShowWriteDrawer(true); + } + }, [openWriteToken, view]); + + useEffect(() => { + if (refreshToken > 0) { + void loadData(); + } + }, [refreshToken]); + + const usersById = useMemo(() => { + const map = new Map(); + users.forEach((user) => map.set(user.id, user)); + return map; + }, [users]); + + const readersById = useMemo(() => { + const map = new Map(); + readers.forEach((reader) => map.set(reader.id, reader)); + return map; + }, [readers]); + + const writableReaders = useMemo( + () => readers.filter((reader) => reader.provisioning_status === 'approved' && reader.is_active && reader.can_write_cards), + [readers] + ); + + const pendingWriteJobs = useMemo( + () => writeJobs.filter((job) => job.status === 'pending' || job.status === 'claimed').length, + [writeJobs] + ); + + const loadData = async ({ silent = false }: { silent?: boolean } = {}) => { + try { + if (!silent) setLoading(true); + + const [readerData, cardData, jobData, tapData, sessionData, userData] = await Promise.all([ + espService.getReaders(true), + espService.getCards(true), + espService.getWriteJobs(500), + espService.getTaps(500), + espService.getAttendance(false, 500), + userService.getAllUsers() + ]); + + setReaders(readerData); + setCards(cardData); + setWriteJobs(jobData); + setTaps(tapData); + setSessions(sessionData); + setUsers(userData); + } catch (error: any) { + setNotice({ type: 'error', text: error.response?.data?.detail || 'Failed to load ESP RFID data.' }); + } finally { + if (!silent) setLoading(false); + } + }; + + const runAction = async (key: string, action: () => Promise, success?: string) => { + try { + setBusyAction(key); + setNotice(null); + await action(); + if (success) { + setNotice({ type: 'success', text: success }); + } + await loadData(); + } catch (error: any) { + setNotice({ type: 'error', text: error.response?.data?.detail || 'Request failed.' }); + } finally { + setBusyAction(null); + } + }; + + const compareValues = (left: string | number, right: string | number) => { + if (typeof left === 'number' && typeof right === 'number') return left - right; + return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); + }; + + const getUserName = (userId: number | null) => { + if (!userId) return 'Unassigned'; + const user = usersById.get(userId); + return user ? `${user.first_name} ${user.last_name}` : `User #${userId}`; + }; + + const getUserDetail = (userId: number | null) => { + if (!userId) return 'No linked member'; + const user = usersById.get(userId); + return user ? user.email : `User #${userId}`; + }; + + const getReaderName = (readerId: number) => readersById.get(readerId)?.name || `Reader #${readerId}`; + + const formatDateTime = (value: string | null) => { + return formatLondonDateTime(value); + }; + + const formatRelativeTime = (value: string | null) => { + if (!value) return 'Never'; + const deltaSeconds = Math.max(0, Math.round((Date.now() - utcMillis(value)) / 1000)); + if (deltaSeconds < 10) return 'Just now'; + if (deltaSeconds < 60) return `${deltaSeconds}s ago`; + if (deltaSeconds < 3600) return `${Math.round(deltaSeconds / 60)}m ago`; + if (deltaSeconds < 86400) return `${Math.round(deltaSeconds / 3600)}h ago`; + return `${Math.round(deltaSeconds / 86400)}d ago`; + }; + + const getReaderStatusTone = (reader: EspReader) => { + if (reader.provisioning_status === 'pending') return 'status-pending'; + if (reader.provisioning_status === 'rejected' || !reader.is_active) return 'status-expired'; + return 'status-active'; + }; + + const getWriteStatusTone = (status: RfidWriteJob['status']) => { + if (status === 'completed') return 'status-active'; + if (status === 'failed' || status === 'cancelled') return 'status-expired'; + return 'status-pending'; + }; + + const startEditReader = (reader: EspReader) => { + setEditingReader(reader); + setReaderEditForm({ + name: reader.name, + location: reader.location || '', + notes: reader.notes || '', + can_write_cards: reader.can_write_cards, + is_active: reader.is_active + }); + }; + + const saveReaderEdit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!editingReader) return; + + await runAction( + `reader-save-${editingReader.id}`, + async () => { + await espService.updateReader(editingReader.id, { + name: readerEditForm.name, + location: readerEditForm.location || null, + notes: readerEditForm.notes || null, + can_write_cards: readerEditForm.can_write_cards, + is_active: readerEditForm.is_active + }); + setEditingReader(null); + }, + 'Reader updated.' + ); + }; + + const queueWriteJob = async (event: React.FormEvent) => { + event.preventDefault(); + await runAction( + 'queue-write', + async () => { + await espService.queueWriteJob({ + reader_id: Number(writeForm.reader_id), + user_id: Number(writeForm.user_id), + label: writeForm.label.trim() + }); + setWriteForm({ reader_id: '', user_id: '', label: '' }); + setShowWriteDrawer(false); + }, + 'Card write queued. The selected reader will pick it up on its next job poll.' + ); + }; + + const deleteReader = async (reader: EspReader) => { + const confirmed = await confirm({ + title: 'Delete reader', + message: `Delete ${reader.name}? This will remove the reader and its RFID tap/session/write-job history.`, + confirmLabel: 'Delete', + tone: 'danger' + }); + if (!confirmed) return; + + await runAction( + `delete-reader-${reader.id}`, + () => espService.deleteReader(reader.id).then(() => undefined), + 'Reader deleted.' + ); + }; + + const filteredReaders = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + return readers.filter((reader) => { + const matchesSearch = + normalizedSearch === '' || + [reader.name, reader.device_id, reader.location || '', reader.notes || ''] + .some((value) => value.toLowerCase().includes(normalizedSearch)); + + const matchesStatus = + statusFilter === 'all' || + (statusFilter === 'pending' && reader.provisioning_status === 'pending') || + (statusFilter === 'approved' && reader.provisioning_status === 'approved') || + (statusFilter === 'rejected' && reader.provisioning_status === 'rejected') || + (statusFilter === 'inactive' && reader.provisioning_status !== 'rejected' && !reader.is_active); + + return matchesSearch && matchesStatus; + }); + }, [readers, searchTerm, statusFilter]); + + const sortedReaders = useMemo(() => { + return [...filteredReaders].sort((left, right) => { + let result = 0; + + switch (readerSortKey) { + case 'reader': + result = compareValues(left.name, right.name); + break; + case 'status': { + const leftRank = left.provisioning_status === 'pending' ? 0 : left.provisioning_status === 'approved' && left.is_active ? 1 : left.provisioning_status === 'approved' ? 2 : 3; + const rightRank = right.provisioning_status === 'pending' ? 0 : right.provisioning_status === 'approved' && right.is_active ? 1 : right.provisioning_status === 'approved' ? 2 : 3; + result = compareValues(leftRank, rightRank); + break; + } + case 'capability': + result = compareValues(left.can_write_cards ? 0 : 1, right.can_write_cards ? 0 : 1); + break; + case 'lastSeen': + result = compareValues(utcMillis(left.last_seen_at), utcMillis(right.last_seen_at)); + break; + } + + if (result === 0) { + const leftPendingRank = left.provisioning_status === 'pending' ? 0 : 1; + const rightPendingRank = right.provisioning_status === 'pending' ? 0 : 1; + result = compareValues(leftPendingRank, rightPendingRank); + } + + if (result === 0) { + result = compareValues(left.name, right.name); + } + + return readerSortDirection === 'asc' ? result : -result; + }); + }, [filteredReaders, readerSortDirection, readerSortKey]); + + const filteredCards = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + return cards.filter((card) => { + const matchesSearch = + normalizedSearch === '' || + [card.uid, card.label || '', getUserName(card.user_id), getUserDetail(card.user_id)] + .some((value) => value.toLowerCase().includes(normalizedSearch)); + + const matchesStatus = + statusFilter === 'all' || + (statusFilter === 'active' && card.is_active) || + (statusFilter === 'inactive' && !card.is_active); + + return matchesSearch && matchesStatus; + }); + }, [cards, searchTerm, statusFilter, usersById]); + + const sortedCards = useMemo(() => { + return [...filteredCards].sort((left, right) => { + let result = 0; + + switch (cardSortKey) { + case 'uid': + result = compareValues(left.uid, right.uid); + break; + case 'user': + result = compareValues(getUserName(left.user_id), getUserName(right.user_id)); + break; + case 'label': + result = compareValues(left.label || 'zzz', right.label || 'zzz'); + break; + case 'status': + result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1); + break; + case 'updated': + result = compareValues(utcMillis(left.updated_at), utcMillis(right.updated_at)); + break; + } + + if (result === 0) { + result = compareValues(left.uid, right.uid); + } + + return cardSortDirection === 'asc' ? result : -result; + }); + }, [cardSortDirection, cardSortKey, filteredCards, usersById]); + + const filteredWriteJobs = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + return writeJobs.filter((job) => { + const matchesSearch = + normalizedSearch === '' || + [ + job.label, + getUserName(job.user_id), + getUserDetail(job.user_id), + getReaderName(job.reader_id), + job.card_uid || '', + job.error_message || '' + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + + const matchesStatus = + statusFilter === 'all' || + statusFilter === job.status; + + return matchesSearch && matchesStatus; + }); + }, [getUserDetail, searchTerm, statusFilter, usersById, writeJobs, readersById]); + + const sortedWriteJobs = useMemo(() => { + return [...filteredWriteJobs].sort((left, right) => { + let result = 0; + + switch (writeSortKey) { + case 'created': + result = compareValues(utcMillis(left.created_at), utcMillis(right.created_at)); + break; + case 'label': + result = compareValues(left.label, right.label); + break; + case 'user': + result = compareValues(getUserName(left.user_id), getUserName(right.user_id)); + break; + case 'reader': + result = compareValues(getReaderName(left.reader_id), getReaderName(right.reader_id)); + break; + case 'status': + result = compareValues(left.status, right.status); + break; + } + + if (result === 0) { + result = compareValues(utcMillis(left.created_at), utcMillis(right.created_at)); + } + + return writeSortDirection === 'asc' ? result : -result; + }); + }, [filteredWriteJobs, writeSortDirection, writeSortKey, usersById, readersById]); + + const filteredSessions = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + return sessions.filter((session) => { + if (normalizedSearch === '') { + return true; + } + + return [ + getUserName(session.user_id), + getUserDetail(session.user_id), + getReaderName(session.reader_id), + session.checkout_source || '', + session.system_flag_reason || '' + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }, [getUserDetail, readersById, searchTerm, sessions, usersById]); + + const sortedSessions = useMemo(() => { + return [...filteredSessions].sort((left, right) => { + let result = 0; + + switch (sessionSortKey) { + case 'member': + result = compareValues(getUserName(left.user_id), getUserName(right.user_id)); + break; + case 'reader': + result = compareValues(getReaderName(left.reader_id), getReaderName(right.reader_id)); + break; + case 'checkedIn': + result = compareValues(utcMillis(left.checked_in_at), utcMillis(right.checked_in_at)); + break; + case 'checkedOut': + result = compareValues(utcMillis(left.checked_out_at), utcMillis(right.checked_out_at)); + break; + case 'duration': + result = compareValues(left.duration_seconds || 0, right.duration_seconds || 0); + break; + case 'status': + result = compareValues(left.is_open ? 0 : 1, right.is_open ? 0 : 1); + break; + } + + if (result === 0) { + result = compareValues(utcMillis(left.checked_in_at), utcMillis(right.checked_in_at)); + } + + return sessionSortDirection === 'asc' ? result : -result; + }); + }, [filteredSessions, readersById, sessionSortDirection, sessionSortKey, usersById]); + + const filteredTaps = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + return taps.filter((tap) => { + if (normalizedSearch === '') { + return true; + } + + return [ + getReaderName(tap.reader_id), + tap.card_uid, + getUserName(tap.user_id), + getUserDetail(tap.user_id), + tap.action, + tap.message || '' + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }, [getUserDetail, readersById, searchTerm, taps, usersById]); + + const sortedTaps = useMemo(() => { + return [...filteredTaps].sort((left, right) => { + let result = 0; + + switch (tapSortKey) { + case 'time': + result = compareValues(utcMillis(left.tapped_at), utcMillis(right.tapped_at)); + break; + case 'reader': + result = compareValues(getReaderName(left.reader_id), getReaderName(right.reader_id)); + break; + case 'card': + result = compareValues(left.card_uid, right.card_uid); + break; + case 'member': + result = compareValues(getUserName(left.user_id), getUserName(right.user_id)); + break; + case 'action': + result = compareValues(left.action, right.action); + break; + } + + if (result === 0) { + result = compareValues(utcMillis(left.tapped_at), utcMillis(right.tapped_at)); + } + + return tapSortDirection === 'asc' ? result : -result; + }); + }, [filteredTaps, readersById, tapSortDirection, tapSortKey, usersById]); + + const totalReaderPages = Math.max(1, Math.ceil(sortedReaders.length / pageSize)); + const totalCardPages = Math.max(1, Math.ceil(sortedCards.length / pageSize)); + const totalJobPages = Math.max(1, Math.ceil(sortedWriteJobs.length / pageSize)); + const totalSessionPages = Math.max(1, Math.ceil(sortedSessions.length / pageSize)); + const totalTapPages = Math.max(1, Math.ceil(sortedTaps.length / pageSize)); + + useEffect(() => { + setReaderPage(1); + }, [searchTerm, statusFilter, readerSortDirection, readerSortKey]); + + useEffect(() => { + setCardPage(1); + }, [searchTerm, statusFilter, cardSortDirection, cardSortKey]); + + useEffect(() => { + setJobPage(1); + }, [searchTerm, statusFilter, writeSortDirection, writeSortKey]); + + useEffect(() => { + setSessionPage(1); + }, [searchTerm, sessionSortDirection, sessionSortKey]); + + useEffect(() => { + setTapPage(1); + }, [searchTerm, tapSortDirection, tapSortKey]); + + useEffect(() => { + if (readerPage > totalReaderPages) setReaderPage(totalReaderPages); + }, [readerPage, totalReaderPages]); + + useEffect(() => { + if (cardPage > totalCardPages) setCardPage(totalCardPages); + }, [cardPage, totalCardPages]); + + useEffect(() => { + if (jobPage > totalJobPages) setJobPage(totalJobPages); + }, [jobPage, totalJobPages]); + + useEffect(() => { + if (sessionPage > totalSessionPages) setSessionPage(totalSessionPages); + }, [sessionPage, totalSessionPages]); + + useEffect(() => { + if (tapPage > totalTapPages) setTapPage(totalTapPages); + }, [tapPage, totalTapPages]); + + const paginatedReaders = sortedReaders.slice((readerPage - 1) * pageSize, readerPage * pageSize); + const paginatedCards = sortedCards.slice((cardPage - 1) * pageSize, cardPage * pageSize); + const paginatedWriteJobs = sortedWriteJobs.slice((jobPage - 1) * pageSize, jobPage * pageSize); + const paginatedSessions = sortedSessions.slice((sessionPage - 1) * pageSize, sessionPage * pageSize); + const paginatedTaps = sortedTaps.slice((tapPage - 1) * pageSize, tapPage * pageSize); + + const toggleSort = ( + activeKey: T, + setKey: React.Dispatch>, + setDirection: React.Dispatch>, + nextKey: T, + defaultDirection: 'asc' | 'desc' = 'asc' + ) => { + if (activeKey === nextKey) { + setDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setKey(nextKey); + setDirection(defaultDirection); + }; + + const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => ( + + + + ); + + if (loading) { + return
Loading ESP RFID system...
; + } + + return ( +
+ {notice && ( +
+ {notice.text} +
+ )} + + {latestApiKey && ( +
+ New API key for {latestApiKey.deviceId} + {latestApiKey.apiKey} + This is only shown once. Re-register the reader if you need server-delivered provisioning again. +
+ )} + + {view === 'readers' && ( +
+
+ + + + + + + + + + + + {paginatedReaders.length === 0 ? ( + + + + ) : ( + paginatedReaders.map((reader) => ( + + + + + + + + )) + )} + +
+ + + + + + + + Actions
No readers match the current filters.
+ {reader.name} +
{reader.device_id} · {reader.location || 'No location'}
+
+ + {reader.provisioning_status === 'approved' && !reader.is_active ? 'inactive' : reader.provisioning_status} + + + {reader.can_write_cards ? 'Read / write' : 'Read only'} +
Firmware {reader.firmware_version || 'unknown'}
+
+
+ {formatRelativeTime(reader.last_seen_at)} + {formatDateTime(reader.last_seen_at)} +
+
+
+ {reader.provisioning_status === 'pending' && ( + <> + + + + )} + + + +
+
+
+
+ Page {readerPage} of {totalReaderPages} +
+ + +
+
+
+ )} + + {view === 'cards' && ( +
+
+ + + + + + + + + + + + {paginatedCards.length === 0 ? ( + + + + ) : ( + paginatedCards.map((card) => ( + + + + + + + + )) + )} + +
+ + + + + + + + + +
No RFID cards match the current filters.
{card.uid} + {getUserName(card.user_id)} +
{getUserDetail(card.user_id)}
+
{card.label || Unlabelled} + + {card.is_active ? 'active' : 'inactive'} + + +
+ {formatRelativeTime(card.updated_at)} + {formatDateTime(card.updated_at)} +
+
+
+
+ Page {cardPage} of {totalCardPages} +
+ + +
+
+
+ )} + + {view === 'writing' && ( +
+
+ + + + + + + + + + + + + + + {paginatedWriteJobs.length === 0 ? ( + + + + ) : ( + paginatedWriteJobs.map((job) => ( + + + + + + + + + + + )) + )} + +
+ + + + + + + + + + Card UIDErrorActions
No write jobs match the current filters.
+
+ {formatRelativeTime(job.created_at)} + {formatDateTime(job.created_at)} +
+
{job.label} + {getUserName(job.user_id)} +
{getUserDetail(job.user_id)}
+
{getReaderName(job.reader_id)} + {job.status} + {job.card_uid ? {job.card_uid} : Pending}{job.error_message || None} + {(job.status === 'pending' || job.status === 'claimed') ? ( +
+ +
+ ) : ( + - + )} +
+
+
+ Page {jobPage} of {totalJobPages} +
+ + +
+
+
+ )} + + {view === 'activity' && ( + <> +
+
+ + + + + + + + + + + + + {paginatedSessions.length === 0 ? ( + + + + ) : ( + paginatedSessions.map((session) => ( + + + + + + + + + )) + )} + +
+ + + + + + + + + + + +
No attendance sessions match the current search.
+ {getUserName(session.user_id)} +
{getUserDetail(session.user_id)}
+
{getReaderName(session.reader_id)} +
+ {formatRelativeTime(session.checked_in_at)} + {formatDateTime(session.checked_in_at)} +
+
+ {session.is_open ? ( + Still checked in + ) : ( +
+ {formatRelativeTime(session.checked_out_at)} + {formatDateTime(session.checked_out_at)} +
+ )} +
{session.duration_seconds === null ? Open : `${Math.round(session.duration_seconds / 60)} min`} + + {session.is_open ? 'open' : 'closed'} + +
+
+
+ Sessions page {sessionPage} of {totalSessionPages} +
+ + +
+
+
+ +
+
+ + + + + + + + + + + + + {paginatedTaps.length === 0 ? ( + + + + ) : ( + paginatedTaps.map((tap) => ( + + + + + + + + + )) + )} + +
+ + + + + + + + + + Message
No tap records match the current search.
+
+ {formatRelativeTime(tap.tapped_at)} + {formatDateTime(tap.tapped_at)} +
+
{getReaderName(tap.reader_id)}{tap.card_uid} + {getUserName(tap.user_id)} +
{getUserDetail(tap.user_id)}
+
+ + {tap.action.replace('_', ' ')} + + {tap.message || No message}
+
+
+ Taps page {tapPage} of {totalTapPages} +
+ + +
+
+
+ + )} + + {editingReader && ( +
setEditingReader(null)}> +