From 1a7ef098055cf6896ddbaeb635f8034f7751b684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=A4henb=C3=BChl?= Date: Mon, 25 May 2026 22:45:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Armarium=20v1.1.0=20=E2=80=94=20dashboa?= =?UTF-8?q?rd,=20auth,=202FA,=20SMTP,=20settings,=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart - KPI cards: income, fixed costs, savings rate with configurable goal - Greeting with time-of-day and locale-aware date/time display Authentication & security: - Email-based login (no username), case-insensitive lookup - JWT access/refresh tokens with rotation and blacklist - TOTP 2FA with QR code, backup codes (copy + PDF export) - 2FA recovery via email code - Cloudflare Turnstile CAPTCHA on login and register Email flows: - Email verification on registration (24h token) - Password reset flow (15min token, anti-enumeration) - Brevo SMTP integration with HTML + plaintext email templates - Notification emails: 2FA recovery, password changed, email changed Settings page: - 2FA management (enable/disable, QR, backup codes) - Active sessions list with per-device revoke - Data export: ZIP with 6 PDFs via fpdf2 - Notification preferences (3 toggles) - Danger zone: account deletion with mandatory export + confirmation phrase UI & layout: - Sidebar with collapsible/flyout mode, Angular signal-based dropdowns - Dark mode (class-based), language switcher (DE/FR/IT/EN) - Mobile-responsive layout with touch-friendly targets - Roboto font via @fontsource (GDPR-compliant, no Google CDN) - Pure Tailwind CSS v3 Infrastructure: - Forgejo Actions CI/CD pipeline (auto-deploy on push to main) - Gunicorn + Nginx + PostgreSQL production setup - Rate limiting, HSTS, secure cookies, CSRF protection --- .forgejo/workflows/deploy.yml | 37 + .gitignore | 7 + CHANGELOG.md | 142 +- CLAUDE.md | 35 + backend | 1 - backend/.env.example | 24 + backend/.gitignore | 20 + backend/core/__init__.py | 0 backend/core/asgi.py | 16 + backend/core/settings.py | 169 + backend/core/urls.py | 55 + backend/core/wsgi.py | 16 + backend/finance/__init__.py | 0 backend/finance/admin.py | 5 + backend/finance/apps.py | 5 + backend/finance/backends.py | 14 + backend/finance/email.py | 33 + backend/finance/migrations/0001_initial.py | 25 + .../finance/migrations/0002_transaction.py | 25 + backend/finance/migrations/0003_budget.py | 26 + .../finance/migrations/0004_alter_budget.py | 37 + .../0005_add_subscriptions_category.py | 18 + .../migrations/0006_add_expense_model.py | 26 + .../migrations/0007_add_profile_model.py | 23 + .../migrations/0008_add_avatar_image.py | 18 + .../finance/migrations/0009_add_user_fk.py | 44 + .../migrations/0010_add_calendar_fields.py | 37 + backend/finance/migrations/0011_readevent.py | 28 + .../migrations/0012_profile_language.py | 18 + .../finance/migrations/0013_profile_totp.py | 21 + .../finance/migrations/0014_totp_security.py | 36 + .../migrations/0015_profile_recovery_email.py | 16 + .../migrations/0016_profile_recovery_code.py | 21 + .../0017_user_sessions_notifications.py | 47 + .../0018_profile_savings_rate_goal.py | 28 + ...e_email_verification_and_password_reset.py | 33 + .../0020_email_verify_token_expiry.py | 18 + backend/finance/migrations/__init__.py | 0 backend/finance/models.py | 226 + backend/finance/serializers.py | 74 + backend/finance/tests.py | 3 + backend/finance/views.py | 999 ++ backend/manage.py | 22 + backend/requirements.txt | 19 + backend/templates/emails/2fa_recovery.html | 34 + backend/templates/emails/2fa_recovery.txt | 18 + backend/templates/emails/base.html | 46 + backend/templates/emails/email_changed.html | 33 + backend/templates/emails/email_changed.txt | 12 + .../templates/emails/password_changed.html | 24 + backend/templates/emails/password_changed.txt | 10 + backend/templates/emails/password_reset.html | 35 + backend/templates/emails/password_reset.txt | 16 + .../emails/registration_confirm.html | 33 + .../templates/emails/registration_confirm.txt | 15 + frontend | 1 - frontend/.editorconfig | 17 + frontend/.gitignore | 44 + frontend/.prettierrc | 12 + frontend/.vscode/extensions.json | 4 + frontend/.vscode/launch.json | 20 + frontend/.vscode/mcp.json | 9 + frontend/.vscode/tasks.json | 42 + frontend/README.md | 59 + frontend/angular.json | 82 + frontend/package-lock.json | 10713 ++++++++++++++++ frontend/package.json | 42 + frontend/postcss.config.js | 6 + frontend/proxy.conf.json | 10 + frontend/public/favicon.ico | Bin 0 -> 15086 bytes .../accounts/account-list/account-list.css | 0 .../accounts/account-list/account-list.html | 227 + .../account-list/account-list.spec.ts | 22 + .../app/accounts/account-list/account-list.ts | 122 + frontend/src/app/app.config.ts | 44 + frontend/src/app/app.css | 0 frontend/src/app/app.html | 1 + frontend/src/app/app.routes.ts | 40 + frontend/src/app/app.spec.ts | 23 + frontend/src/app/app.ts | 10 + .../auth/forgot-password/forgot-password.html | 96 + .../auth/forgot-password/forgot-password.ts | 49 + .../app/auth/lang-switcher/lang-switcher.ts | 74 + frontend/src/app/auth/login/login.html | 266 + frontend/src/app/auth/login/login.ts | 249 + frontend/src/app/auth/register/register.html | 142 + frontend/src/app/auth/register/register.ts | 69 + .../auth/reset-password/reset-password.html | 139 + .../app/auth/reset-password/reset-password.ts | 70 + frontend/src/app/auth/turnstile/turnstile.ts | 53 + .../app/auth/verify-email/verify-email.html | 69 + .../src/app/auth/verify-email/verify-email.ts | 40 + frontend/src/app/budgets/budgets.css | 0 frontend/src/app/budgets/budgets.html | 327 + frontend/src/app/budgets/budgets.spec.ts | 22 + frontend/src/app/budgets/budgets.ts | 231 + frontend/src/app/calendar/calendar.html | 328 + frontend/src/app/calendar/calendar.ts | 331 + frontend/src/app/dashboard/dashboard.css | 0 frontend/src/app/dashboard/dashboard.html | 246 + frontend/src/app/dashboard/dashboard.spec.ts | 22 + frontend/src/app/dashboard/dashboard.ts | 358 + frontend/src/app/data/swiss-holidays.ts | 177 + .../expenses/expense-list/expense-list.css | 0 .../expenses/expense-list/expense-list.html | 342 + .../app/expenses/expense-list/expense-list.ts | 210 + frontend/src/app/guards/auth.guard.ts | 12 + .../src/app/interceptors/auth.interceptor.ts | 28 + frontend/src/app/layout/navbar/navbar.css | 0 frontend/src/app/layout/navbar/navbar.html | 219 + frontend/src/app/layout/navbar/navbar.spec.ts | 22 + frontend/src/app/layout/navbar/navbar.ts | 133 + frontend/src/app/layout/shell/shell.html | 16 + frontend/src/app/layout/shell/shell.ts | 32 + frontend/src/app/layout/sidebar/sidebar.css | 0 frontend/src/app/layout/sidebar/sidebar.html | 270 + frontend/src/app/layout/sidebar/sidebar.ts | 63 + frontend/src/app/profile/profile.css | 0 frontend/src/app/profile/profile.html | 183 + frontend/src/app/profile/profile.ts | 138 + frontend/src/app/services/api.spec.ts | 16 + frontend/src/app/services/api.ts | 197 + frontend/src/app/services/auth.ts | 83 + frontend/src/app/services/holidays.ts | 91 + frontend/src/app/services/language.ts | 48 + frontend/src/app/services/notification.ts | 55 + frontend/src/app/services/sidebar.spec.ts | 16 + frontend/src/app/services/sidebar.ts | 41 + frontend/src/app/services/theme.ts | 33 + frontend/src/app/settings/settings.html | 452 + frontend/src/app/settings/settings.ts | 287 + .../transaction-list/transaction-list.css | 0 .../transaction-list/transaction-list.html | 253 + .../transaction-list/transaction-list.spec.ts | 22 + .../transaction-list/transaction-list.ts | 148 + frontend/src/assets/Icon.svg | 3 + frontend/src/assets/Logo_horizontal.svg | 4 + frontend/src/assets/Logo_vertikal.svg | 4 + frontend/src/assets/i18n/de.json | 404 + frontend/src/assets/i18n/en.json | 404 + frontend/src/assets/i18n/fr.json | 404 + frontend/src/assets/i18n/it.json | 404 + frontend/src/index.html | 14 + frontend/src/main.ts | 6 + frontend/src/styles.css | 32 + frontend/tailwind.config.js | 34 + frontend/tsconfig.app.json | 15 + frontend/tsconfig.json | 33 + frontend/tsconfig.spec.json | 15 + nginx.conf | 28 + 150 files changed, 22862 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 CLAUDE.md delete mode 160000 backend create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/core/__init__.py create mode 100644 backend/core/asgi.py create mode 100644 backend/core/settings.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/wsgi.py create mode 100644 backend/finance/__init__.py create mode 100644 backend/finance/admin.py create mode 100644 backend/finance/apps.py create mode 100644 backend/finance/backends.py create mode 100644 backend/finance/email.py create mode 100644 backend/finance/migrations/0001_initial.py create mode 100644 backend/finance/migrations/0002_transaction.py create mode 100644 backend/finance/migrations/0003_budget.py create mode 100644 backend/finance/migrations/0004_alter_budget.py create mode 100644 backend/finance/migrations/0005_add_subscriptions_category.py create mode 100644 backend/finance/migrations/0006_add_expense_model.py create mode 100644 backend/finance/migrations/0007_add_profile_model.py create mode 100644 backend/finance/migrations/0008_add_avatar_image.py create mode 100644 backend/finance/migrations/0009_add_user_fk.py create mode 100644 backend/finance/migrations/0010_add_calendar_fields.py create mode 100644 backend/finance/migrations/0011_readevent.py create mode 100644 backend/finance/migrations/0012_profile_language.py create mode 100644 backend/finance/migrations/0013_profile_totp.py create mode 100644 backend/finance/migrations/0014_totp_security.py create mode 100644 backend/finance/migrations/0015_profile_recovery_email.py create mode 100644 backend/finance/migrations/0016_profile_recovery_code.py create mode 100644 backend/finance/migrations/0017_user_sessions_notifications.py create mode 100644 backend/finance/migrations/0018_profile_savings_rate_goal.py create mode 100644 backend/finance/migrations/0019_profile_email_verification_and_password_reset.py create mode 100644 backend/finance/migrations/0020_email_verify_token_expiry.py create mode 100644 backend/finance/migrations/__init__.py create mode 100644 backend/finance/models.py create mode 100644 backend/finance/serializers.py create mode 100644 backend/finance/tests.py create mode 100644 backend/finance/views.py create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 backend/templates/emails/2fa_recovery.html create mode 100644 backend/templates/emails/2fa_recovery.txt create mode 100644 backend/templates/emails/base.html create mode 100644 backend/templates/emails/email_changed.html create mode 100644 backend/templates/emails/email_changed.txt create mode 100644 backend/templates/emails/password_changed.html create mode 100644 backend/templates/emails/password_changed.txt create mode 100644 backend/templates/emails/password_reset.html create mode 100644 backend/templates/emails/password_reset.txt create mode 100644 backend/templates/emails/registration_confirm.html create mode 100644 backend/templates/emails/registration_confirm.txt delete mode 160000 frontend create mode 100644 frontend/.editorconfig create mode 100644 frontend/.gitignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/launch.json create mode 100644 frontend/.vscode/mcp.json create mode 100644 frontend/.vscode/tasks.json create mode 100644 frontend/README.md create mode 100644 frontend/angular.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/proxy.conf.json create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/app/accounts/account-list/account-list.css create mode 100644 frontend/src/app/accounts/account-list/account-list.html create mode 100644 frontend/src/app/accounts/account-list/account-list.spec.ts create mode 100644 frontend/src/app/accounts/account-list/account-list.ts create mode 100644 frontend/src/app/app.config.ts create mode 100644 frontend/src/app/app.css create mode 100644 frontend/src/app/app.html create mode 100644 frontend/src/app/app.routes.ts create mode 100644 frontend/src/app/app.spec.ts create mode 100644 frontend/src/app/app.ts create mode 100644 frontend/src/app/auth/forgot-password/forgot-password.html create mode 100644 frontend/src/app/auth/forgot-password/forgot-password.ts create mode 100644 frontend/src/app/auth/lang-switcher/lang-switcher.ts create mode 100644 frontend/src/app/auth/login/login.html create mode 100644 frontend/src/app/auth/login/login.ts create mode 100644 frontend/src/app/auth/register/register.html create mode 100644 frontend/src/app/auth/register/register.ts create mode 100644 frontend/src/app/auth/reset-password/reset-password.html create mode 100644 frontend/src/app/auth/reset-password/reset-password.ts create mode 100644 frontend/src/app/auth/turnstile/turnstile.ts create mode 100644 frontend/src/app/auth/verify-email/verify-email.html create mode 100644 frontend/src/app/auth/verify-email/verify-email.ts create mode 100644 frontend/src/app/budgets/budgets.css create mode 100644 frontend/src/app/budgets/budgets.html create mode 100644 frontend/src/app/budgets/budgets.spec.ts create mode 100644 frontend/src/app/budgets/budgets.ts create mode 100644 frontend/src/app/calendar/calendar.html create mode 100644 frontend/src/app/calendar/calendar.ts create mode 100644 frontend/src/app/dashboard/dashboard.css create mode 100644 frontend/src/app/dashboard/dashboard.html create mode 100644 frontend/src/app/dashboard/dashboard.spec.ts create mode 100644 frontend/src/app/dashboard/dashboard.ts create mode 100644 frontend/src/app/data/swiss-holidays.ts create mode 100644 frontend/src/app/expenses/expense-list/expense-list.css create mode 100644 frontend/src/app/expenses/expense-list/expense-list.html create mode 100644 frontend/src/app/expenses/expense-list/expense-list.ts create mode 100644 frontend/src/app/guards/auth.guard.ts create mode 100644 frontend/src/app/interceptors/auth.interceptor.ts create mode 100644 frontend/src/app/layout/navbar/navbar.css create mode 100644 frontend/src/app/layout/navbar/navbar.html create mode 100644 frontend/src/app/layout/navbar/navbar.spec.ts create mode 100644 frontend/src/app/layout/navbar/navbar.ts create mode 100644 frontend/src/app/layout/shell/shell.html create mode 100644 frontend/src/app/layout/shell/shell.ts create mode 100644 frontend/src/app/layout/sidebar/sidebar.css create mode 100644 frontend/src/app/layout/sidebar/sidebar.html create mode 100644 frontend/src/app/layout/sidebar/sidebar.ts create mode 100644 frontend/src/app/profile/profile.css create mode 100644 frontend/src/app/profile/profile.html create mode 100644 frontend/src/app/profile/profile.ts create mode 100644 frontend/src/app/services/api.spec.ts create mode 100644 frontend/src/app/services/api.ts create mode 100644 frontend/src/app/services/auth.ts create mode 100644 frontend/src/app/services/holidays.ts create mode 100644 frontend/src/app/services/language.ts create mode 100644 frontend/src/app/services/notification.ts create mode 100644 frontend/src/app/services/sidebar.spec.ts create mode 100644 frontend/src/app/services/sidebar.ts create mode 100644 frontend/src/app/services/theme.ts create mode 100644 frontend/src/app/settings/settings.html create mode 100644 frontend/src/app/settings/settings.ts create mode 100644 frontend/src/app/transactions/transaction-list/transaction-list.css create mode 100644 frontend/src/app/transactions/transaction-list/transaction-list.html create mode 100644 frontend/src/app/transactions/transaction-list/transaction-list.spec.ts create mode 100644 frontend/src/app/transactions/transaction-list/transaction-list.ts create mode 100644 frontend/src/assets/Icon.svg create mode 100644 frontend/src/assets/Logo_horizontal.svg create mode 100644 frontend/src/assets/Logo_vertikal.svg create mode 100644 frontend/src/assets/i18n/de.json create mode 100644 frontend/src/assets/i18n/en.json create mode 100644 frontend/src/assets/i18n/fr.json create mode 100644 frontend/src/assets/i18n/it.json create mode 100644 frontend/src/index.html create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.spec.json create mode 100644 nginx.conf diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..0542992 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,37 @@ +name: Deploy to Production + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: production + steps: + - name: Pull latest code + run: | + cd /home/armarium/armarium-suite + git fetch origin + git reset --hard origin/main + + - name: Backend — install dependencies + run: | + cd /home/armarium/armarium-suite/backend + source venv/bin/activate + pip install -r requirements.txt --quiet + + - name: Backend — run migrations + run: | + cd /home/armarium/armarium-suite/backend + source venv/bin/activate + python manage.py migrate --no-input + + - name: Frontend — build + run: | + cd /home/armarium/armarium-suite/frontend + npm install --silent + npm run build -- --configuration production + + - name: Restart service + run: sudo systemctl restart armarium diff --git a/.gitignore b/.gitignore index 747006d..ca12de0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # ── Temporäre Upload-Ordner ─────────────────────────────────────────────────── Logos_Armarium/ +# ── Lokales Referenzmaterial (nicht für VCS) ───────────────────────────────── +flowbite-reference/ +flowbite-admin-dashboard-v2.2.0.zip +flowbite-admin-dashboard-v2.2.0.zip:Zone.Identifier + # ── Persönliche Entwicklungsnotizen (nicht für VCS) ────────────────────────── backend/commands.md backend/tasks.json @@ -17,3 +22,5 @@ Thumbs.db # ── Claude Code (lokale Konfiguration) ─────────────────────────────────────── .claude/ .mcp.json +flowbite-admin-dashboard-v2.2.0.zipZone.Identifier +start.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4228e..8d52ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,147 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.0] - 2026-05-19 + +### Added +- Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus +- Auth: Passwort vergessen / Reset — `POST /api/auth/password-reset/` (anti-enumeration); `POST /api/auth/password-reset/confirm/` setzt Passwort und invalidiert alle aktiven Sessions; Token (SHA-256-Hash, 15min TTL) via Brevo-Mail mit Link +- Auth: `ForgotPassword`-Komponente (`/forgot-password`), `ResetPassword`-Komponente (`/reset-password`), `VerifyEmail`-Komponente (`/verify-email`) +- Auth: "Passwort vergessen?"-Link auf Login-Seite +- E-Mail-Templates: `registration_confirm`, `password_reset`, `password_changed`, `email_changed` (je HTML + Plaintext) +- Backend: `finance/email.py` — generischer `send_email()` Helper mit `EmailMultiAlternatives` +- Backend: `FRONTEND_URL` Env-Var für absolute Links in Mails; `EMAIL_BACKEND` via Env-Var überschreibbar +- i18n: `auth.forgot_password`, `auth.reset_password`, `auth.new_password`, `auth.email_verified` + Error-Keys (DE/EN/FR/IT) +- Feature: Jahresplanung (`/financial-year`) — Jahres-Dropdown, 3 Summary-Cards, Tabs Einnahmen/Fixkosten mit Inline-CRUD; "Neues Jahr starten" (max. 1 Jahr im Voraus) +- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019) +- Backend: vollständige REST-API für Jahresplanung und Haushalte inkl. Invite/Accept/Leave/SetRole +- Backend: Django Management Command `migrate_to_financial_year` (idempotent, `--dry-run`) +- Frontend: `FinancialYearService` mit allen Typen und API-Methoden +- Frontend: Household-Sektion auf `/financial-year` (Gründen, Einladen, Rollen, Annehmen, Verlassen) +- Sidebar: "Jahresplanung" Nav-Item +- Dashboard: Einnahmen/Fixkosten aus `FinancialYearService` statt Account/Budget-Daten +- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign, 3 Serien, Jahres-Dropdown +- Dashboard: Fixkostenaufschlüsselung — Pie Chart mit %-Labels, Toggle zur Listenansicht +- Dashboard: Sparquote — personalisierbarer Ziel-Marker, Settings-Toggle zum Anpassen +- Security: Cloudflare Turnstile CAPTCHA auf Login + Register +- Infrastructure: Brevo SMTP (`smtp-relay.brevo.com:587`), Domain `armarium.ch` verifiziert (SPF/DKIM) +- i18n: `sidebar.financial_year`, `financial_year.*`, `dashboard.*`, `auth.errors.captcha_failed` (DE/EN/FR/IT) +- Settings: Active Sessions card — lists all logged-in devices (device name, IP, last active); individual revoke and "sign out all others" buttons; current session marked with badge; `UserSession` model with `session_key`, `refresh_jti`, `device_name`, `ip_address`; `_create_session()` called on every successful login (including 2FA and recovery flows) +- Settings: Data Export — downloads a ZIP containing 6 structured PDFs (Profil, Konten, Budgets, Ausgaben, Transaktionen, Termine) generated server-side with fpdf2; violet header bar, alternating row fill, gray footer with page numbers +- Settings: Notification Preferences — toggles for "Anstehende Termine", "Budget-Warnungen", "Monatliche Zusammenfassung"; saved via `PATCH /api/notifications/prefs/`; loaded from profile on page open +- Settings: Account deletion now requires two steps — (1) mandatory data export, (2) confirmation form with password field (show/hide eye icon) and translated confirmation phrase (`profile.delete_account` in current language); delete button disabled until both conditions met +- Settings: After account deletion, user is redirected to `https://www.armarium.ch` and both storages are cleared +- Auth: Language switcher (`LangSwitcher` component) inside the login and register cards (top-left); uses `@HostListener` for outside-click close and `[class]` binding to avoid Tailwind dark-mode colon conflicts +- Auth: "Angemeldet bleiben" checkbox on login with two-line label; toggles between `localStorage` (persistent) and `sessionStorage` (session-only); persisted through full 2FA and recovery flows via `keepSignedIn` parameter +- Auth: `session_key` stored alongside JWT tokens in same storage; sent as `X-Session-Key` header by interceptor so backend can identify current session +- Backend: `PATCH /api/notifications/prefs/` endpoint (`NotificationPrefsView`) +- Backend: `GET /api/auth/sessions/`, `DELETE /api/auth/sessions//`, `DELETE /api/auth/sessions/revoke-all/` endpoints +- Backend: `GET /api/export/` returns ZIP with 6 PDFs via fpdf2 +- Backend: Admin URL configurable via `ADMIN_URL` env var (default `manage/`); obscures the standard `/admin/` path from scanners +- Backend: SMTP email config from env vars (`EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS`, `DEFAULT_FROM_EMAIL`); console backend in DEBUG mode +- Backend: production security block (`if not DEBUG`) — `SECURE_SSL_REDIRECT`, HSTS (1 year, preload, subdomains), secure cookies, `SECURE_CONTENT_TYPE_NOSNIFF`, proxy SSL header +- Backend: `DATA_UPLOAD_MAX_MEMORY_SIZE` and `FILE_UPLOAD_MAX_MEMORY_SIZE` capped at 5 MB +- Backend: `CSRF_TRUSTED_ORIGINS` from env var +- i18n: `settings.sessions_*`, `settings.export_*`, `settings.notif_*`, `settings.delete_*` keys (DE/FR/IT/EN) +- i18n: `auth.keep_signed_in`, `auth.keep_signed_in_hint`, `auth.back_to_login` keys (DE/FR/IT/EN) +- Security: TOTP-based two-factor authentication (2FA) — setup via QR code, verified on login; production-ready with HMAC-signed temp token (5 min expiry), replay protection via `totp_last_used_code`, and 8 backup codes in `XXXXXXXX-XXXXXXXX` format (SHA-256 hashed in DB) +- Security: `TwoFactorSetupView`, `TwoFactorEnableView`, `TwoFactorDisableView`, `TwoFactorLoginView` backend endpoints; `BackupCode` model with index on `(user, used)` +- Settings page (`/settings`): 2FA card (enable/disable, QR scan, backup code copy + PDF download) and Danger Zone (account deletion); accessible from navbar avatar menu +- Login: two-step flow — step 1 credentials, step 2 TOTP/backup code entry when 2FA is active +- Backup codes: copy to clipboard and PDF download via jsPDF (client-side, no server round-trip) +- Dashboard: donut chart "Fixed Costs Breakdown" now shows individual budget entry names and amounts (was: grouped by category) +- Dashboard: toggle button (top-right of donut card) switches between chart view and a scrollable breakdown list with color dot, name, CHF amount and percentage per entry +- i18n: `auth.totp_title`, `auth.totp_hint`, `auth.totp_or_backup`, `auth.back_to_login`, `auth.errors.invalid_totp` keys (DE/FR/IT/EN) +- i18n: `profile.totp_*`, `profile.backup_*` keys for all 2FA labels, backup codes and messages (DE/FR/IT/EN) +- i18n: `settings.subtitle` key (DE/FR/IT/EN) + +### Changed +- `.env.example`: `FRONTEND_URL` und `EMAIL_BACKEND` Variablen ergänzt +- Profile-Model: `email_verify_token` neu als SHA-256-Hash gespeichert; `email_verify_token_expires` (24h TTL) hinzugefügt (Migration 0020) +- **UI:** migrated frontend to Flowbite design system — custom Tailwind v4 theme (`budget-app-theme.css`) with violet primary color, consistent rounded-lg cards, Flowbite outline SVG icons throughout +- **Navbar:** avatar dropdown and notification panel converted from Flowbite JS (`data-dropdown-toggle`) to Angular state management (`avatarDropdownOpen` signal + backdrop `
` for outside-click close); eliminates Flowbite JS runtime dependency +- **Navbar:** sun/moon/bell icons replaced with Flowbite outline variants; logout entry in avatar dropdown now shows `arrow-right-to-bracket` icon +- **Sidebar:** navigation icons restored to fill style (`fill="currentColor" viewBox="0 0 20 20"`) for consistency with pre-migration appearance; Settings link added to mobile drawer after Profile link +- **Dashboard:** greeting H1 changed to `font-light` (weight 300) +- **Mobile — tables:** non-critical columns hidden on small screens (`hidden sm:table-cell` for category, `hidden md:table-cell` for account) in expense and transaction lists +- **Mobile — touch targets:** all icon-only buttons (edit, delete, modal close, calendar navigation arrows) updated to `p-2` minimum (was `p-1.5`) +- **Mobile — form grids:** `grid-cols-2` changed to `grid-cols-1 sm:grid-cols-2` in expense modals and profile form +- **Mobile — budget entries:** `min-w-0 flex-1` and `truncate` on name/account spans prevent overflow on narrow screens +- **Mobile — calendar:** day cells `min-h-[48px] sm:min-h-[64px]`, day detail drawer `w-full sm:w-80` +- **Mobile — dashboard:** KPI card padding `p-3 sm:p-5`, KPI values `text-xl sm:text-2xl` +- **Mobile — login:** OTP digit inputs `h-10 w-10 sm:h-12 sm:w-12`; backup code field placeholder removed (hint text above suffices) +- **Mobile — settings:** recovery email row `flex-col sm:flex-row` so button stacks below input on narrow screens +- **Search:** placeholder text styled `placeholder-gray-400` (was unstyled) +- Typography: Roboto font self-hosted via `@fontsource/roboto` (300/400/500/700 weights) — no Google Fonts CDN, DSGVO/nDSG compliant; replaces Inter which was defined in the theme but never actually loaded +- Typography: unified font-size system — page title H1 `text-2xl` (24px), card/section H2 headers `text-base` (16px), sidebar navigation `text-sm` (14px), savings rate display `text-3xl` (30px); applied across Dashboard, Budgets, Expenses, Calendar, Profile, Settings and Sidebar +- Profile: password change fields no longer show browser autofill suggestions (`autocomplete="new-password"`); show/hide eye icon added to both password fields +- Sidebar: version number updated to 1.1.0 +- Login: registration is email-only — username field removed; backend auto-sets `username=email`; `RegisterSerializer` updated accordingly +- Login: font sizes increased throughout login/register flow (`text-xs`→`text-sm`, `text-sm`→`text-base`, card `max-w-sm`→`max-w-md`) +- Settings: 2FA card and Danger Zone remain; Recovery Email, Active Sessions, Data Export, Notification Preferences added above Danger Zone +- Settings: Danger Zone delete flow is now three-step (export → credentials + phrase → redirect) +- `LogoutView`: added `permission_classes = [AllowAny]` so logout works even when access token is expired; now also deletes `UserSession` by JTI and `X-Session-Key` +- `ChangePasswordView`: invalidates and blacklists all other active sessions on password change +- `authInterceptor`: sends `X-Session-Key` header on all internal API requests +- Auth: `completeLogin()` and `storeTokens()` accept optional `session_key`; `logout()` clears session key from both storages +- Auth: `refreshToken()` bug fixed — was storing `tokens.access` twice instead of `tokens.access` and `tokens.refresh` +- Login endpoint `POST /api/auth/token/` replaced with custom `LoginView` supporting 2FA challenge response +- Profile page: 2FA and Danger Zone sections removed and moved to new Settings page +- Authenticator app recommendation updated to: Proton Pass, Aegis (Android), Raivo OTP (iOS) +- Calendar: holidays and school holidays now shown in the current app language; reloaded automatically on language change via `translate.onLangChange` subscription +- Calendar: OpenHolidays API requests include `languageIsoCode` parameter; cache key includes language +- Calendar: today's date shown as violet ring/outline only (not filled) to prevent white-on-white hover text +- Notifications: "Mark as read" checkmark button per notification (replaces X icon) +- Notifications: "Mark all as read" button in panel header +- i18n: `nav.mark_read`, `nav.mark_all_read` keys (DE/FR/IT/EN) +- Calendar: live holiday and school holiday data via OpenHolidays API (openholidaysapi.org, AGPL-3.0) with automatic fallback to static data on API failure +- Calendar: in-memory cache per year/canton to avoid redundant API requests +- Budgets: show info modal when no accounts exist, with link button to accounts page +- Expenses: show info modal when no accounts exist, with link button to accounts page +- i18n: `common.no_accounts_title`, `common.no_accounts_text`, `common.go_to_accounts` keys (DE/FR/IT/EN) +- Login: authentication changed from username-based to email-based; password manager only needs email + password +- Mobile sidebar drawer: Notifications, Dark/Light toggle, Profile link and Logout moved from navbar into the sidebar so all user actions are accessible via the hamburger menu + +### Security +- Password Reset invalidiert alle aktiven Sessions des Nutzers (analog zu `ChangePasswordView`) +- Email-Verify-Token: SHA-256-Hash statt Klartext in DB; Ablaufzeit 24h +- `VerifyEmailView` + `PasswordResetConfirmView` mit `AuthThrottle` (5/min) gesichert + +### Fixed +- Security: `TwoFactorRecoverConfirmView` now verifies `temp_token` (password proof) before accepting a recovery code — previously anyone with a valid recovery code could bypass authentication entirely +- Security: `ProfileView.delete` now requires password verification (`check_password`) before deletion; returns 403 on failure +- Backend: URL conflict — `api/notifications/` was mapped to both `NotificationsView` and `NotificationPrefsView`; prefs endpoint moved to `api/notifications/prefs/` +- Backend: fpdf2 export — em/en dash characters (`—`, `–`) caused `FPDFUnicodeEncodingException` with Helvetica (Latin-1 only); fixed with `safe()` helper using `encode('latin-1', errors='replace')` and replaced placeholder dashes with ASCII `-` +- Backend: migration 0017 (`finance_profile.notif_deadlines` etc.) was not applied on server restart, causing 500 on login; now documented that `python manage.py migrate` must be run after deployment +- Auth: `LangSwitcher` — `[class.dark:text-violet-400]` Angular binding error caused by Tailwind dark-mode colon in class name; fixed by using `itemClass()` method with `[class]` binding +- Auth: `LangSwitcher` — `current` signal initialized before `langService` was available; fixed by setting default `'de'` and calling `this.current.set(langService.current)` in constructor body +- Dashboard: bar chart right padding increased so December bars are no longer clipped at the container edge +- Calendar: legend block removed from footer +- Calendar: ZH Spring Holidays 2026 corrected to 20.04.–02.05. in static fallback data (source: OpenHolidays API) +- Calendar: date input now renders in the app language format (lang attribute bound to current language) +- Calendar: deadline type dropdown now shows placeholder on open; defaults to "other" if none selected +- Calendar: title input placeholder text styled correctly in gray +- Login: show/hide password toggle with eye icon +- Register: show/hide password toggle on both password fields, independently toggleable +- Register: hint text below username field explaining it is used as in-app display name (DE/FR/IT/EN) +- i18n: `nav.dark_mode`, `nav.light_mode` keys for sidebar mobile theme toggle (DE/FR/IT/EN) +- i18n: `auth.username_hint` key for register page (DE/FR/IT/EN) +- Mobile navbar: right-side icons (notifications, theme toggle, avatar) caused the navbar to wrap to a second line on narrow screens, pushing page content below the fixed offset; icons are now hidden on mobile and integrated into the sidebar drawer +- Mobile notification panel: was full-width and flush against the top of the screen; now has `left-4`/`right-4` margins, `top-20` offset and `rounded-xl` corners +- Auth: new `EmailAuthBackend` performs case-insensitive email lookup so login works regardless of capitalisation +- Login/Register: placeholder texts removed from all username and password fields + +--- + +## [1.0.1] - 2026-04-14 + +### Fixed +- Production deployment: replaced hardcoded `http://127.0.0.1:8000` with relative paths (`/api`, `/api/auth`) in `ApiService` and `AuthService` so the frontend works on any server +- Production deployment: avatar image URLs in `profile.ts` and `navbar.ts` now use the relative path returned by the backend instead of prepending `http://127.0.0.1:8000` +- Sidebar: Budgets and Accounts submenus not expanding on mobile — replaced Flowbite `data-collapse-toggle` with Angular signal-based state (`budgetsOpen`, `accountsOpen` in `SidebarService`) + +### Added +- `nginx.conf`: reverse proxy config — serves Angular static build, proxies `/api/` to Gunicorn on port 8000, serves `/media/` as static files --- diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7621cb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# Armarium — Claude Code Instructions + +## Icons + +**Immer inline SVG verwenden — kein Flowbite, kein fb-icon, keine externe Icon-Komponente.** + +SVG-Icons direkt aus [Flowbite Icons](https://flowbite.com/icons/) oder ähnlichen Quellen kopieren und inline einbetten: + +```html + + + + + + + + + +``` + +- Farbe via Tailwind-Klassen: `text-gray-400`, `text-red-500`, etc. +- Grösse via `w-X h-X` Klassen +- `fill="none"` + `stroke="currentColor"` für Outline-Icons +- `fill="currentColor"` für Solid-Icons + +--- + +## Stack + +- **Frontend:** Angular 21, Standalone Components, Signals, Tailwind CSS v3, ngx-translate +- **Backend:** Django 5, DRF, PostgreSQL +- **i18n:** DE / FR / IT / EN — immer alle 4 Sprachen gleichzeitig aktualisieren +- **CSS:** Reines Tailwind v3 — kein Flowbite, kein @tailwindcss/postcss diff --git a/backend b/backend deleted file mode 160000 index 980535b..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 980535b2a4c75aa8ed02cc78d92c3e580b72925a diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..04638cf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +SECRET_KEY=your-secret-key-here +DEBUG=False +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database +DB_NAME=budget_db +DB_USER=budget_user +DB_PASSWORD=your-db-password +DB_HOST=localhost +DB_PORT=5432 + +# Cloudflare Turnstile (https://dash.cloudflare.com → Turnstile) +TURNSTILE_SECRET_KEY=your-turnstile-secret-key + +# Email / Brevo SMTP (https://app.brevo.com → SMTP & API → SMTP) +EMAIL_HOST=smtp-relay.brevo.com +EMAIL_PORT=587 +EMAIL_HOST_USER=your-brevo-smtp-login +EMAIL_HOST_PASSWORD=your-brevo-smtp-password +EMAIL_USE_TLS=True +DEFAULT_FROM_EMAIL=noreply@armarium.ch + +# Frontend URL (used in emails for links) +FRONTEND_URL=https://app.armarium.ch diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f4a0be4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +venv/ +.venv/ +env/ + +# Django +db.sqlite3 +.env +static/ +media/ + +# Logs +*.log +logs/ + +# Betriebssystem +.DS_Store \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/asgi.py b/backend/core/asgi.py new file mode 100644 index 0000000..cf099bf --- /dev/null +++ b/backend/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_asgi_application() diff --git a/backend/core/settings.py b/backend/core/settings.py new file mode 100644 index 0000000..17a4009 --- /dev/null +++ b/backend/core/settings.py @@ -0,0 +1,169 @@ +from pathlib import Path +from datetime import timedelta +import os +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / '.env') + +SECRET_KEY = os.environ['SECRET_KEY'] + +DEBUG = os.environ.get('DEBUG', 'False') == 'True' + +TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '') + +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', + 'corsheaders', + 'finance', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +CORS_ALLOWED_ORIGINS = os.environ.get( + 'CORS_ALLOWED_ORIGINS', 'http://localhost:4200' +).split(',') + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME', 'budget_db'), + 'USER': os.environ.get('DB_USER', 'budget_user'), + 'PASSWORD': os.environ['DB_PASSWORD'], + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# ── Email ───────────────────────────────────────────────────────────────────── +_default_email_backend = ( + 'django.core.mail.backends.console.EmailBackend' if DEBUG + else 'django.core.mail.backends.smtp.EmailBackend' +) +EMAIL_BACKEND = os.environ.get('EMAIL_BACKEND', _default_email_backend) +EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost') +EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '587')) +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True' +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch') + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': {'class': 'logging.StreamHandler'}, + }, + 'loggers': { + 'django.mail': {'handlers': ['console'], 'level': 'ERROR'}, + 'armarium': {'handlers': ['console'], 'level': 'INFO'}, + }, +} + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '20/min', + 'user': '200/min', + 'auth': '5/min', + }, +} + +AUTHENTICATION_BACKENDS = [ + 'finance.backends.EmailAuthBackend', +] + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, +} + +# ── Uploads ─────────────────────────────────────────────────────────────────── +DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB +FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB + +# ── CSRF ────────────────────────────────────────────────────────────────────── +CSRF_TRUSTED_ORIGINS = os.environ.get( + 'CSRF_TRUSTED_ORIGINS', 'http://localhost:4200' +).split(',') + +# ── Production security (nur aktiv wenn DEBUG=False) ───────────────────────── +if not DEBUG: + SECURE_SSL_REDIRECT = True + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + SECURE_HSTS_SECONDS = 31_536_000 # 1 Jahr + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_CONTENT_TYPE_NOSNIFF = True + +FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200') diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..a79c45a --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,55 @@ +import os +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenRefreshView +from finance.views import ( + AccountViewSet, TransactionViewSet, BudgetViewSet, + ExpenseViewSet, DeadlineViewSet, ProfileView, RegisterView, LogoutView, ChangePasswordView, + ICalUrlView, ICalFeedView, NotificationsView, SearchView, + LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView, + TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView, + SessionListView, SessionRevokeView, SessionRevokeAllView, + DataExportView, NotificationPrefsView, + VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView, +) + +router = DefaultRouter() +router.register(r'accounts', AccountViewSet, basename='account') +router.register(r'transactions', TransactionViewSet, basename='transaction') +router.register(r'budgets', BudgetViewSet, basename='budget') +router.register(r'expenses', ExpenseViewSet, basename='expense') +router.register(r'deadlines', DeadlineViewSet, basename='deadline') + +_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/' + +urlpatterns = [ + path(_admin_url, admin.site.urls), + path('api/', include(router.urls)), + path('api/profile/', ProfileView.as_view()), + path('api/auth/register/', RegisterView.as_view()), + path('api/auth/token/', LoginView.as_view()), + path('api/auth/token/refresh/', TokenRefreshView.as_view()), + path('api/auth/logout/', LogoutView.as_view()), + path('api/auth/password/', ChangePasswordView.as_view()), + path('api/auth/verify-email/', VerifyEmailView.as_view()), + path('api/auth/password-reset/', PasswordResetRequestView.as_view()), + path('api/auth/password-reset/confirm/', PasswordResetConfirmView.as_view()), + path('api/auth/2fa/login/', TwoFactorLoginView.as_view()), + path('api/auth/2fa/setup/', TwoFactorSetupView.as_view()), + path('api/auth/2fa/enable/', TwoFactorEnableView.as_view()), + path('api/auth/2fa/disable/', TwoFactorDisableView.as_view()), + path('api/auth/2fa/recover/', TwoFactorRecoverRequestView.as_view()), + path('api/auth/2fa/recover/confirm/', TwoFactorRecoverConfirmView.as_view()), + path('api/auth/sessions/', SessionListView.as_view()), + path('api/auth/sessions/revoke-all/', SessionRevokeAllView.as_view()), + path('api/auth/sessions//', SessionRevokeView.as_view()), + path('api/export/', DataExportView.as_view()), + path('api/notifications/prefs/', NotificationPrefsView.as_view()), + path('api/search/', SearchView.as_view()), + path('api/notifications/', NotificationsView.as_view()), + path('api/calendar/ical-url/', ICalUrlView.as_view()), + path('api/calendar/ical///', ICalFeedView.as_view()), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py new file mode 100644 index 0000000..6d36530 --- /dev/null +++ b/backend/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/backend/finance/__init__.py b/backend/finance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/admin.py b/backend/finance/admin.py new file mode 100644 index 0000000..cb1e3e0 --- /dev/null +++ b/backend/finance/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import Account, Transaction + +admin.site.register(Account) +admin.site.register(Transaction) \ No newline at end of file diff --git a/backend/finance/apps.py b/backend/finance/apps.py new file mode 100644 index 0000000..06859cb --- /dev/null +++ b/backend/finance/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FinanceConfig(AppConfig): + name = 'finance' diff --git a/backend/finance/backends.py b/backend/finance/backends.py new file mode 100644 index 0000000..9668dac --- /dev/null +++ b/backend/finance/backends.py @@ -0,0 +1,14 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model + + +class EmailAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + User = get_user_model() + try: + user = User.objects.get(email__iexact=username) + except (User.DoesNotExist, User.MultipleObjectsReturned): + return None + if user.check_password(password) and self.user_can_authenticate(user): + return user + return None diff --git a/backend/finance/email.py b/backend/finance/email.py new file mode 100644 index 0000000..e106559 --- /dev/null +++ b/backend/finance/email.py @@ -0,0 +1,33 @@ +import logging +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) + + +def send_email(template_name: str, context: dict, subject: str, to: str | list[str]) -> bool: + """ + Render and send an HTML email with plaintext fallback. + Returns True on success, False on failure. + """ + if isinstance(to, str): + to = [to] + + html = render_to_string(f'emails/{template_name}.html', context) + text = render_to_string(f'emails/{template_name}.txt', context) + + msg = EmailMultiAlternatives( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=to, + ) + msg.attach_alternative(html, 'text/html') + + try: + msg.send() + return True + except Exception: + logger.exception('Failed to send email "%s" to %s', template_name, to) + return False diff --git a/backend/finance/migrations/0001_initial.py b/backend/finance/migrations/0001_initial.py new file mode 100644 index 0000000..78d8ee3 --- /dev/null +++ b/backend/finance/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.3 on 2026-03-08 17:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Account', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('account_type', models.CharField(choices=[('asset', 'Asset Account (Bank/Cash)'), ('expense', 'Expense Account (Laden/Empfänger)'), ('revenue', 'Revenue Account (Einnahmequelle)')], default='asset', max_length=20)), + ('balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12)), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/backend/finance/migrations/0002_transaction.py b/backend/finance/migrations/0002_transaction.py new file mode 100644 index 0000000..48aa7f5 --- /dev/null +++ b/backend/finance/migrations/0002_transaction.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.3 on 2026-03-08 17:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('date', models.DateField()), + ('destination_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deposits', to='finance.account')), + ('source_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='finance.account')), + ], + ), + ] diff --git a/backend/finance/migrations/0003_budget.py b/backend/finance/migrations/0003_budget.py new file mode 100644 index 0000000..19ecc7b --- /dev/null +++ b/backend/finance/migrations/0003_budget.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-03-15 16:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0002_transaction'), + ] + + operations = [ + migrations.CreateModel( + name='Budget', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('category', models.CharField(choices=[('housing', 'Wohnen'), ('food', 'Lebensmittel'), ('transport', 'Transport'), ('health', 'Gesundheit'), ('entertainment', 'Freizeit'), ('savings', 'Sparen'), ('other', 'Sonstiges')], default='other', max_length=50)), + ('due_day', models.PositiveSmallIntegerField(help_text='Tag im Monat (1-31)')), + ('active', models.BooleanField(default=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budgets', to='finance.account')), + ], + ), + ] diff --git a/backend/finance/migrations/0004_alter_budget.py b/backend/finance/migrations/0004_alter_budget.py new file mode 100644 index 0000000..02da549 --- /dev/null +++ b/backend/finance/migrations/0004_alter_budget.py @@ -0,0 +1,37 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0003_budget'), + ] + + operations = [ + migrations.RemoveField( + model_name='budget', + name='due_day', + ), + migrations.RenameField( + model_name='budget', + old_name='category', + new_name='main_category', + ), + migrations.AlterField( + model_name='budget', + name='main_category', + field=models.CharField( + choices=[ + ('fixed_expenses', 'Fixe Ausgaben'), + ('mobile_internet', 'Mobile & Internet'), + ('leisure', 'Freizeit'), + ('tax_reserves', 'Steuerrücklagen'), + ('insurance', 'Versicherungen'), + ('loans', 'Abzahlungen & Kredite'), + ], + default='fixed_expenses', + max_length=50, + ), + ), + ] diff --git a/backend/finance/migrations/0005_add_subscriptions_category.py b/backend/finance/migrations/0005_add_subscriptions_category.py new file mode 100644 index 0000000..742ea5c --- /dev/null +++ b/backend/finance/migrations/0005_add_subscriptions_category.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-23 22:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0004_alter_budget'), + ] + + operations = [ + migrations.AlterField( + model_name='budget', + name='main_category', + field=models.CharField(choices=[('fixed_expenses', 'Fixe Ausgaben'), ('mobile_internet', 'Mobile & Internet'), ('subscriptions', 'Abonnements'), ('leisure', 'Freizeit'), ('tax_reserves', 'Steuerrücklagen'), ('insurance', 'Versicherungen'), ('loans', 'Abzahlungen & Kredite')], default='fixed_expenses', max_length=50), + ), + ] diff --git a/backend/finance/migrations/0006_add_expense_model.py b/backend/finance/migrations/0006_add_expense_model.py new file mode 100644 index 0000000..dedcd1a --- /dev/null +++ b/backend/finance/migrations/0006_add_expense_model.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-03-23 22:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0005_add_subscriptions_category'), + ] + + operations = [ + migrations.CreateModel( + name='Expense', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('date', models.DateField()), + ('category', models.CharField(choices=[('groceries', 'Groceries'), ('dining', 'Dining & Restaurants'), ('transport', 'Transport'), ('health', 'Health & Medical'), ('clothing', 'Clothing'), ('electronics', 'Electronics'), ('household', 'Household'), ('entertainment', 'Entertainment'), ('travel', 'Travel'), ('other', 'Other')], default='other', max_length=50)), + ('notes', models.TextField(blank=True, default='')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='finance.account')), + ], + ), + ] diff --git a/backend/finance/migrations/0007_add_profile_model.py b/backend/finance/migrations/0007_add_profile_model.py new file mode 100644 index 0000000..b5e1521 --- /dev/null +++ b/backend/finance/migrations/0007_add_profile_model.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-03-24 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0006_add_expense_model'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(blank=True, default='', max_length=100)), + ('last_name', models.CharField(blank=True, default='', max_length=100)), + ('email', models.EmailField(blank=True, default='', max_length=254)), + ('avatar_color', models.CharField(default='#1A56DB', max_length=7)), + ], + ), + ] diff --git a/backend/finance/migrations/0008_add_avatar_image.py b/backend/finance/migrations/0008_add_avatar_image.py new file mode 100644 index 0000000..af184fc --- /dev/null +++ b/backend/finance/migrations/0008_add_avatar_image.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-24 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0007_add_profile_model'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='avatar_image', + field=models.ImageField(blank=True, null=True, upload_to='avatars/'), + ), + ] diff --git a/backend/finance/migrations/0009_add_user_fk.py b/backend/finance/migrations/0009_add_user_fk.py new file mode 100644 index 0000000..0942fd3 --- /dev/null +++ b/backend/finance/migrations/0009_add_user_fk.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.3 on 2026-03-24 19:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def assign_legacy_user(apps, schema_editor): + User = apps.get_model('auth', 'User') + Account = apps.get_model('finance', 'Account') + Profile = apps.get_model('finance', 'Profile') + + legacy_user, _ = User.objects.get_or_create( + username='legacy_user', + defaults={'email': '', 'is_active': True}, + ) + + Account.objects.filter(user__isnull=True).update(user=legacy_user) + + for profile in Profile.objects.filter(user__isnull=True): + profile.user = legacy_user + profile.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0008_add_avatar_image'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='profile', + name='user', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL), + ), + migrations.RunPython(assign_legacy_user, migrations.RunPython.noop), + ] diff --git a/backend/finance/migrations/0010_add_calendar_fields.py b/backend/finance/migrations/0010_add_calendar_fields.py new file mode 100644 index 0000000..a23a5bf --- /dev/null +++ b/backend/finance/migrations/0010_add_calendar_fields.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.3 on 2026-03-24 20:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0009_add_user_fk'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='due_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='canton', + field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zurich')], default='ZH', max_length=2), + ), + migrations.CreateModel( + name='Deadline', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('date', models.DateField()), + ('type', models.CharField(choices=[('tax', 'Tax'), ('insurance', 'Insurance'), ('invoice', 'Invoice'), ('personal', 'Personal'), ('other', 'Other')], default='other', max_length=20)), + ('notes', models.TextField(blank=True, default='')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deadlines', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/finance/migrations/0011_readevent.py b/backend/finance/migrations/0011_readevent.py new file mode 100644 index 0000000..1a03011 --- /dev/null +++ b/backend/finance/migrations/0011_readevent.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-04-12 18:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0010_add_calendar_fields'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReadEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('deadline', 'Deadline'), ('expense', 'Expense')], max_length=20)), + ('event_id', models.PositiveIntegerField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'event_type', 'event_id')}, + }, + ), + ] diff --git a/backend/finance/migrations/0012_profile_language.py b/backend/finance/migrations/0012_profile_language.py new file mode 100644 index 0000000..a9b0ae9 --- /dev/null +++ b/backend/finance/migrations/0012_profile_language.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-12 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0011_readevent'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='language', + field=models.CharField(choices=[('de', 'Deutsch'), ('fr', 'Français'), ('it', 'Italiano'), ('en', 'English')], default='de', max_length=2), + ), + ] diff --git a/backend/finance/migrations/0013_profile_totp.py b/backend/finance/migrations/0013_profile_totp.py new file mode 100644 index 0000000..5208d1a --- /dev/null +++ b/backend/finance/migrations/0013_profile_totp.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0012_profile_language'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='totp_secret', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='profile', + name='totp_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/finance/migrations/0014_totp_security.py b/backend/finance/migrations/0014_totp_security.py new file mode 100644 index 0000000..0da48da --- /dev/null +++ b/backend/finance/migrations/0014_totp_security.py @@ -0,0 +1,36 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0013_profile_totp'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='totp_last_used_code', + field=models.CharField(blank=True, default='', max_length=6), + ), + migrations.CreateModel( + name='BackupCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code_hash', models.CharField(max_length=64)), + ('used', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='backup_codes', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'indexes': [models.Index(fields=['user', 'used'], name='finance_bac_user_id_idx')], + }, + ), + ] diff --git a/backend/finance/migrations/0015_profile_recovery_email.py b/backend/finance/migrations/0015_profile_recovery_email.py new file mode 100644 index 0000000..e312b63 --- /dev/null +++ b/backend/finance/migrations/0015_profile_recovery_email.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0014_totp_security'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='recovery_email', + field=models.EmailField(blank=True, default=''), + ), + ] diff --git a/backend/finance/migrations/0016_profile_recovery_code.py b/backend/finance/migrations/0016_profile_recovery_code.py new file mode 100644 index 0000000..aeae467 --- /dev/null +++ b/backend/finance/migrations/0016_profile_recovery_code.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0015_profile_recovery_email'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='recovery_code_hash', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='profile', + name='recovery_code_expires', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/finance/migrations/0017_user_sessions_notifications.py b/backend/finance/migrations/0017_user_sessions_notifications.py new file mode 100644 index 0000000..18f3fb6 --- /dev/null +++ b/backend/finance/migrations/0017_user_sessions_notifications.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0016_profile_recovery_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='notif_deadlines', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='profile', + name='notif_budget_alerts', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='profile', + name='notif_monthly_summary', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='UserSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_key', models.CharField(max_length=64, unique=True)), + ('refresh_jti', models.CharField(blank=True, default='', max_length=255)), + ('device_name', models.CharField(blank=True, default='', max_length=200)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_active_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='user_sessions', + to=settings.AUTH_USER_MODEL, + )), + ], + options={'ordering': ['-last_active_at']}, + ), + ] diff --git a/backend/finance/migrations/0018_profile_savings_rate_goal.py b/backend/finance/migrations/0018_profile_savings_rate_goal.py new file mode 100644 index 0000000..b6e2cd1 --- /dev/null +++ b/backend/finance/migrations/0018_profile_savings_rate_goal.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-05-18 19:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0017_user_sessions_notifications'), + ] + + operations = [ + migrations.RenameIndex( + model_name='backupcode', + new_name='finance_bac_user_id_7e357d_idx', + old_name='finance_bac_user_id_idx', + ), + migrations.AddField( + model_name='profile', + name='savings_rate_goal', + field=models.PositiveSmallIntegerField(default=20), + ), + migrations.AlterField( + model_name='profile', + name='canton', + field=models.CharField(choices=[('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), ('ZG', 'Zug'), ('ZH', 'Zürich')], default='ZH', max_length=2), + ), + ] diff --git a/backend/finance/migrations/0019_profile_email_verification_and_password_reset.py b/backend/finance/migrations/0019_profile_email_verification_and_password_reset.py new file mode 100644 index 0000000..ff0d5dd --- /dev/null +++ b/backend/finance/migrations/0019_profile_email_verification_and_password_reset.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.4 on 2026-05-19 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0018_profile_savings_rate_goal'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='email_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='email_verify_token', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='profile', + name='password_reset_token_expires', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='password_reset_token_hash', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/finance/migrations/0020_email_verify_token_expiry.py b/backend/finance/migrations/0020_email_verify_token_expiry.py new file mode 100644 index 0000000..28f9e6e --- /dev/null +++ b/backend/finance/migrations/0020_email_verify_token_expiry.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-05-19 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0019_profile_email_verification_and_password_reset'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='email_verify_token_expires', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/finance/migrations/__init__.py b/backend/finance/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/models.py b/backend/finance/models.py new file mode 100644 index 0000000..52652bf --- /dev/null +++ b/backend/finance/models.py @@ -0,0 +1,226 @@ +from django.db import models +from django.conf import settings + +class Account(models.Model): + # Typen basierend auf der Firefly III Logik + ACCOUNT_TYPES = [ + ('asset', 'Asset Account (Bank/Cash)'), + ('expense', 'Expense Account (Laden/Empfänger)'), + ('revenue', 'Revenue Account (Einnahmequelle)'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='accounts', + null=True, + ) + name = models.CharField(max_length=100) + account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset') + balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} ({self.get_account_type_display()})" + +class Transaction(models.Model): + description = models.CharField(max_length=255) + amount = models.DecimalField(max_digits=12, decimal_places=2) + date = models.DateField() + + # Die Verknüpfung zu den Konten (Double-Entry Prinzip) + source_account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + related_name='withdrawals' + ) + destination_account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + related_name='deposits' + ) + + def __str__(self): + return f"{self.date}: {self.description} ({self.amount}€)" + + +class Budget(models.Model): + MAIN_CATEGORY_CHOICES = [ + ('fixed_expenses', 'Fixe Ausgaben'), + ('mobile_internet', 'Mobile & Internet'), + ('subscriptions', 'Abonnements'), + ('leisure', 'Freizeit'), + ('tax_reserves', 'Steuerrücklagen'), + ('insurance', 'Versicherungen'), + ('loans', 'Abzahlungen & Kredite'), + ] + + name = models.CharField(max_length=100) + amount = models.DecimalField(max_digits=12, decimal_places=2) + main_category = models.CharField(max_length=50, choices=MAIN_CATEGORY_CHOICES, default='fixed_expenses') + account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + related_name='budgets' + ) + active = models.BooleanField(default=True) + + def __str__(self): + return f"{self.name} ({self.amount} CHF)" + + +class Expense(models.Model): + CATEGORY_CHOICES = [ + ('groceries', 'Groceries'), + ('dining', 'Dining & Restaurants'), + ('transport', 'Transport'), + ('health', 'Health & Medical'), + ('clothing', 'Clothing'), + ('electronics', 'Electronics'), + ('household', 'Household'), + ('entertainment', 'Entertainment'), + ('travel', 'Travel'), + ('other', 'Other'), + ] + + name = models.CharField(max_length=255) + amount = models.DecimalField(max_digits=12, decimal_places=2) + date = models.DateField() + category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='other') + account = models.ForeignKey( + Account, + on_delete=models.CASCADE, + related_name='expenses' + ) + notes = models.TextField(blank=True, default='') + due_date = models.DateField(blank=True, null=True) + + def __str__(self): + return f"{self.date}: {self.name} ({self.amount} CHF)" + + +CANTON_CHOICES = [ + ('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'), + ('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'), + ('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'), + ('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'), + ('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'), + ('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'), + ('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'), + ('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'), + ('ZG', 'Zug'), ('ZH', 'Zürich'), +] + + +class Deadline(models.Model): + TYPE_CHOICES = [ + ('tax', 'Tax'), + ('insurance', 'Insurance'), + ('invoice', 'Invoice'), + ('personal', 'Personal'), + ('other', 'Other'), + ] + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='deadlines', + ) + title = models.CharField(max_length=200) + date = models.DateField() + type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='other') + notes = models.TextField(blank=True, default='') + + def __str__(self): + return f"{self.date}: {self.title}" + + +class Profile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='profile', + null=True, + ) + first_name = models.CharField(max_length=100, blank=True, default='') + last_name = models.CharField(max_length=100, blank=True, default='') + email = models.EmailField(blank=True, default='') + avatar_color = models.CharField(max_length=7, default='#1A56DB') + avatar_image = models.ImageField(upload_to='avatars/', blank=True, null=True) + canton = models.CharField(max_length=2, choices=CANTON_CHOICES, default='ZH') + language = models.CharField(max_length=2, choices=[('de','Deutsch'),('fr','Français'),('it','Italiano'),('en','English')], default='de') + totp_secret = models.CharField(max_length=64, blank=True, default='') + totp_enabled = models.BooleanField(default=False) + totp_last_used_code = models.CharField(max_length=6, blank=True, default='') + recovery_email = models.EmailField(blank=True, default='') + recovery_code_hash = models.CharField(max_length=64, blank=True, default='') + recovery_code_expires = models.DateTimeField(null=True, blank=True) + notif_deadlines = models.BooleanField(default=True) + notif_budget_alerts = models.BooleanField(default=True) + notif_monthly_summary = models.BooleanField(default=False) + savings_rate_goal = models.PositiveSmallIntegerField(default=20) + email_verified = models.BooleanField(default=False) + email_verify_token = models.CharField(max_length=64, blank=True, default='') + email_verify_token_expires = models.DateTimeField(null=True, blank=True) + password_reset_token_hash = models.CharField(max_length=64, blank=True, default='') + password_reset_token_expires = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"{self.first_name} {self.last_name}".strip() or 'Profile' + + +class UserSession(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='user_sessions', + ) + session_key = models.CharField(max_length=64, unique=True) + refresh_jti = models.CharField(max_length=255, blank=True, default='') + device_name = models.CharField(max_length=200, blank=True, default='') + ip_address = models.GenericIPAddressField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + last_active_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-last_active_at'] + + def __str__(self): + return f"{self.user} – {self.device_name}" + + +class ReadEvent(models.Model): + EVENT_TYPES = [ + ('deadline', 'Deadline'), + ('expense', 'Expense'), + ] + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='read_events', + ) + event_type = models.CharField(max_length=20, choices=EVENT_TYPES) + event_id = models.PositiveIntegerField() + + class Meta: + unique_together = ['user', 'event_type', 'event_id'] + + def __str__(self): + return f"{self.user} – {self.event_type} {self.event_id}" + + +class BackupCode(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='backup_codes', + ) + code_hash = models.CharField(max_length=64) + used = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [models.Index(fields=['user', 'used'])] + + def __str__(self): + return f"{self.user} – backup {'used' if self.used else 'active'}" \ No newline at end of file diff --git a/backend/finance/serializers.py b/backend/finance/serializers.py new file mode 100644 index 0000000..2768263 --- /dev/null +++ b/backend/finance/serializers.py @@ -0,0 +1,74 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from .models import Account, Transaction, Budget, Expense, Profile, Deadline + +User = get_user_model() + + +class AccountSerializer(serializers.ModelSerializer): + class Meta: + model = Account + exclude = ['user'] + + +class TransactionSerializer(serializers.ModelSerializer): + class Meta: + model = Transaction + fields = '__all__' + + def validate(self, data): + request = self.context.get('request') + if not request: + return data + user = request.user + source = data.get('source_account') or (self.instance.source_account if self.instance else None) + dest = data.get('destination_account') or (self.instance.destination_account if self.instance else None) + if source and source.user != user: + raise serializers.ValidationError('Source account does not belong to you.') + if dest and dest.user != user: + raise serializers.ValidationError('Destination account does not belong to you.') + return data + + +class BudgetSerializer(serializers.ModelSerializer): + class Meta: + model = Budget + fields = '__all__' + + +class ExpenseSerializer(serializers.ModelSerializer): + class Meta: + model = Expense + fields = '__all__' + + +class ProfileSerializer(serializers.ModelSerializer): + totp_enabled = serializers.BooleanField(read_only=True) + + class Meta: + model = Profile + exclude = ['user', 'totp_secret', 'email_verify_token', 'email_verify_token_expires', 'password_reset_token_hash', 'password_reset_token_expires'] + + +class DeadlineSerializer(serializers.ModelSerializer): + class Meta: + model = Deadline + exclude = ['user'] + + +class RegisterSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(min_length=8, write_only=True) + + def validate_email(self, value): + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError('Email already registered.') + return value + + def create(self, validated_data): + email = validated_data['email'] + return User.objects.create_user( + username=email, + email=email, + password=validated_data['password'], + ) diff --git a/backend/finance/tests.py b/backend/finance/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/finance/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/finance/views.py b/backend/finance/views.py new file mode 100644 index 0000000..0974c98 --- /dev/null +++ b/backend/finance/views.py @@ -0,0 +1,999 @@ +import base64 +import hmac +import hashlib +import json +import logging +import secrets +import time +import urllib.parse +import urllib.request +import pyotp + +logger = logging.getLogger('armarium') +from django.conf import settings +from django.contrib.auth import get_user_model, authenticate +from django.http import HttpResponse +from icalendar import Calendar as iCalendar, Event as iCalEvent + +from rest_framework import viewsets, views, status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.exceptions import TokenError +from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession +from .serializers import ( + AccountSerializer, TransactionSerializer, BudgetSerializer, + ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer, +) + + +def _verify_turnstile(token: str, remote_ip: str = '') -> bool: + if settings.DEBUG: + return True + if not token or not settings.TURNSTILE_SECRET_KEY: + return False + data = urllib.parse.urlencode({ + 'secret': settings.TURNSTILE_SECRET_KEY, + 'response': token, + 'remoteip': remote_ip, + }).encode() + try: + req = urllib.request.Request( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data=data, + method='POST', + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read()).get('success', False) + except Exception: + logger.warning('Turnstile verification request failed') + return False + + +def generate_ical_token(user_id: int) -> str: + return hmac.new( + settings.SECRET_KEY.encode(), + str(user_id).encode(), + hashlib.sha256 + ).hexdigest() + +MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024 # 2 MB + + +class AuthThrottle(AnonRateThrottle): + rate = '5/min' + + +class AccountViewSet(viewsets.ModelViewSet): + serializer_class = AccountSerializer + + def get_queryset(self): + return Account.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TransactionViewSet(viewsets.ModelViewSet): + serializer_class = TransactionSerializer + + def get_queryset(self): + return Transaction.objects.filter(source_account__user=self.request.user) + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + +class BudgetViewSet(viewsets.ModelViewSet): + serializer_class = BudgetSerializer + + def get_queryset(self): + return Budget.objects.filter(account__user=self.request.user) + + +class ExpenseViewSet(viewsets.ModelViewSet): + serializer_class = ExpenseSerializer + + def get_queryset(self): + return Expense.objects.filter(account__user=self.request.user) + + +class DeadlineViewSet(viewsets.ModelViewSet): + serializer_class = DeadlineSerializer + + def get_queryset(self): + return Deadline.objects.filter(user=self.request.user).order_by('date') + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class ProfileView(views.APIView): + def get(self, request): + profile, _ = Profile.objects.get_or_create(user=request.user) + return Response(ProfileSerializer(profile).data) + + def put(self, request): + from .email import send_email + + avatar = request.FILES.get('avatar_image') + if avatar and avatar.size > MAX_AVATAR_SIZE_BYTES: + return Response({'detail': 'Image must be smaller than 2 MB.'}, status=400) + + recovery_email = request.data.get('recovery_email', '').strip().lower() + if recovery_email and recovery_email == request.user.email.lower(): + return Response( + {'recovery_email': 'Recovery email must differ from your login email.'}, + status=400, + ) + + old_email = request.user.email + profile, _ = Profile.objects.get_or_create(user=request.user) + serializer = ProfileSerializer(profile, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + new_email = request.user.email + if new_email != old_email: + send_email( + 'email_changed', + {'new_email': new_email}, + 'Armarium – Deine E-Mail-Adresse wurde geändert', + old_email, + ) + return Response(serializer.data) + return Response(serializer.errors, status=400) + + def delete(self, request): + password = request.data.get('password', '') + if not password or not request.user.check_password(password): + return Response({'detail': 'Passwort ungültig.'}, status=status.HTTP_403_FORBIDDEN) + request.user.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class RegisterView(views.APIView): + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from .email import send_email + + if not _verify_turnstile( + request.data.get('cf_turnstile_response', ''), + request.META.get('REMOTE_ADDR', ''), + ): + return Response({'detail': 'Captcha verification failed.'}, status=status.HTTP_400_BAD_REQUEST) + serializer = RegisterSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + from django.utils import timezone + from datetime import timedelta + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode()).hexdigest() + profile, _ = Profile.objects.get_or_create(user=user) + profile.email_verify_token = token_hash + profile.email_verify_token_expires = timezone.now() + timedelta(hours=24) + profile.save(update_fields=['email_verify_token', 'email_verify_token_expires']) + link = f"{settings.FRONTEND_URL}/verify-email?token={token}" + send_email('registration_confirm', {'link': link}, 'Armarium – E-Mail-Adresse bestätigen', user.email) + return Response({'detail': 'Account created.'}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(views.APIView): + permission_classes = [AllowAny] + + def post(self, request): + refresh_token = request.data.get('refresh') + if not refresh_token: + return Response({'detail': 'Refresh token required.'}, status=400) + try: + token = RefreshToken(refresh_token) + jti = token.payload.get('jti', '') + token.blacklist() + if jti: + UserSession.objects.filter(refresh_jti=jti).delete() + except TokenError: + pass # already invalid, treat as success + + session_key = request.headers.get('X-Session-Key', '') + if session_key: + UserSession.objects.filter(session_key=session_key).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ChangePasswordView(views.APIView): + def post(self, request): + from .email import send_email + + password = request.data.get('password', '') + if len(password) < 8: + return Response({'detail': 'Password must be at least 8 characters.'}, status=400) + request.user.set_password(password) + request.user.save() + + current_key = request.headers.get('X-Session-Key', '') + other_sessions = UserSession.objects.filter(user=request.user).exclude(session_key=current_key) + for session in other_sessions: + _blacklist_session(session) + + send_email('password_changed', {}, 'Armarium – Dein Passwort wurde geändert', request.user.email) + return Response({'detail': 'Password updated.'}) + + +class SearchView(views.APIView): + """Global search across all user resources.""" + + def get(self, request): + q = request.query_params.get('q', '').strip() + if len(q) < 2: + return Response({}) + + user = request.user + results = {} + + accounts = Account.objects.filter(user=user, name__icontains=q)[:5] + if accounts: + results['accounts'] = [ + {'id': a.id, 'title': a.name, 'subtitle': a.get_account_type_display()} + for a in accounts + ] + + budgets = Budget.objects.filter(account__user=user, name__icontains=q)[:5] + if budgets: + results['budgets'] = [ + {'id': b.id, 'title': b.name, 'subtitle': f'CHF {b.amount}'} + for b in budgets + ] + + expenses = Expense.objects.filter(account__user=user, name__icontains=q)[:5] + if expenses: + results['expenses'] = [ + {'id': e.id, 'title': e.name, 'subtitle': f'{e.date} · CHF {e.amount}'} + for e in expenses + ] + + transactions = Transaction.objects.filter( + source_account__user=user, description__icontains=q + )[:5] + if transactions: + results['transactions'] = [ + {'id': t.id, 'title': t.description, 'subtitle': f'{t.date} · CHF {t.amount}'} + for t in transactions + ] + + deadlines = Deadline.objects.filter(user=user, title__icontains=q)[:5] + if deadlines: + results['deadlines'] = [ + {'id': d.id, 'title': d.title, 'subtitle': str(d.date), 'date': str(d.date)} + for d in deadlines + ] + + return Response(results) + + +class NotificationsView(views.APIView): + """Returns all unread active events (date <= today) for the authenticated user.""" + + def get(self, request): + from datetime import date + today = date.today() + + read_deadlines = set( + ReadEvent.objects.filter(user=request.user, event_type='deadline') + .values_list('event_id', flat=True) + ) + read_expenses = set( + ReadEvent.objects.filter(user=request.user, event_type='expense') + .values_list('event_id', flat=True) + ) + + notifications = [] + + for d in Deadline.objects.filter(user=request.user, date__lte=today): + if d.id not in read_deadlines: + notifications.append({ + 'event_type': 'deadline', + 'event_id': d.id, + 'title': d.title, + 'date': str(d.date), + }) + + for e in Expense.objects.filter(account__user=request.user, due_date__lte=today): + if e.id not in read_expenses: + notifications.append({ + 'event_type': 'expense', + 'event_id': e.id, + 'title': e.name, + 'date': str(e.due_date), + }) + + notifications.sort(key=lambda x: x['date']) + return Response(notifications) + + def post(self, request): + """Mark a single event as read.""" + event_type = request.data.get('event_type') + event_id = request.data.get('event_id') + if event_type not in ('deadline', 'expense') or not event_id: + return Response({'detail': 'Invalid payload.'}, status=400) + ReadEvent.objects.get_or_create( + user=request.user, event_type=event_type, event_id=event_id + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ICalUrlView(views.APIView): + """Returns the personal iCal feed URL for the authenticated user.""" + + def get(self, request): + token = generate_ical_token(request.user.id) + base_url = request.build_absolute_uri('/') + url = f"{base_url}api/calendar/ical/{request.user.id}/{token}/" + return Response({'url': url}) + + +class ICalFeedView(views.APIView): + """Serves the iCal feed. Token acts as authentication — no JWT required.""" + permission_classes = [AllowAny] + + def get(self, request, user_id, token): + expected = generate_ical_token(user_id) + if not hmac.compare_digest(expected, token): + return HttpResponse(status=404) + + User = get_user_model() + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + return HttpResponse(status=404) + + cal = iCalendar() + cal.add('prodid', '-//Budget App//EN') + cal.add('version', '2.0') + cal.add('x-wr-calname', 'Budget App') + cal.add('x-wr-timezone', 'Europe/Zurich') + + # Deadlines + for deadline in Deadline.objects.filter(user=user): + event = iCalEvent() + event.add('summary', f'[{deadline.get_type_display()}] {deadline.title}') + event.add('dtstart', deadline.date) + event.add('dtend', deadline.date) + event.add('uid', f'deadline-{deadline.id}@budget-app') + if deadline.notes: + event.add('description', deadline.notes) + cal.add_component(event) + + # Expense due dates + for expense in Expense.objects.filter(account__user=user, due_date__isnull=False): + event = iCalEvent() + event.add('summary', f'[Invoice] {expense.name} – CHF {expense.amount}') + event.add('dtstart', expense.due_date) + event.add('dtend', expense.due_date) + event.add('uid', f'expense-{expense.id}@budget-app') + if expense.notes: + event.add('description', expense.notes) + cal.add_component(event) + + response = HttpResponse(cal.to_ical(), content_type='text/calendar; charset=utf-8') + response['Content-Disposition'] = 'attachment; filename="budget-app.ics"' + return response + + +# ── 2FA helpers ────────────────────────────────────────────────────────────── + +def _make_2fa_token(user_id: int) -> str: + """Create a short-lived signed token binding step-1 to step-2 of login.""" + payload = f"{user_id}:{int(time.time())}" + sig = hmac.new(settings.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest() + return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode() + + +def _verify_2fa_token(token: str, max_age: int = 300) -> int | None: + """Return user_id if token is valid and not expired, else None.""" + try: + decoded = base64.urlsafe_b64decode(token.encode()).decode() + *payload_parts, sig = decoded.split(':') + payload = ':'.join(payload_parts) + user_id_str, ts_str = payload_parts + expected = hmac.new(settings.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, sig): + return None + if int(time.time()) - int(ts_str) > max_age: + return None + return int(user_id_str) + except Exception: + return None + + +def _generate_backup_codes(user, count: int = 8) -> list[str]: + """Invalidate all old backup codes and return a fresh set of plain-text codes.""" + BackupCode.objects.filter(user=user).delete() + plain = [] + for _ in range(count): + code = f"{secrets.token_hex(4).upper()}-{secrets.token_hex(4).upper()}" + BackupCode.objects.create( + user=user, + code_hash=hashlib.sha256(code.encode()).hexdigest(), + ) + plain.append(code) + return plain + + +def _verify_totp_with_replay_check(profile, code: str) -> bool: + """Verify TOTP code and reject replay within the same 30-second window.""" + if profile.totp_last_used_code == code: + return False + totp = pyotp.TOTP(profile.totp_secret) + if not totp.verify(code, valid_window=1): + return False + profile.totp_last_used_code = code + profile.save(update_fields=['totp_last_used_code']) + return True + + +# ── 2FA views ───────────────────────────────────────────────────────────────── + +class LoginView(views.APIView): + """Replaces TokenObtainPairView. Returns a short-lived temp_token when 2FA is required.""" + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + if not _verify_turnstile( + request.data.get('cf_turnstile_response', ''), + request.META.get('REMOTE_ADDR', ''), + ): + return Response({'detail': 'Captcha verification failed.'}, status=status.HTTP_400_BAD_REQUEST) + email = request.data.get('username', '') + password = request.data.get('password', '') + user = authenticate(request, username=email, password=password) + if user is None: + return Response({'detail': 'No active account found with the given credentials.'}, status=401) + + profile, _ = Profile.objects.get_or_create(user=user) + if profile.totp_enabled: + return Response({'2fa_required': True, 'temp_token': _make_2fa_token(user.id)}, status=200) + + refresh = RefreshToken.for_user(user) + session_key = _create_session(user, request, refresh) + return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key}) + + +class TwoFactorLoginView(views.APIView): + """Step 2 of login — accepts TOTP code or backup code, returns JWT tokens.""" + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + temp_token = request.data.get('temp_token', '') + code = str(request.data.get('code', '')).strip() + + user_id = _verify_2fa_token(temp_token) + if user_id is None: + return Response({'detail': 'Session expired. Please log in again.'}, status=401) + + User = get_user_model() + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + return Response({'detail': 'Invalid credentials.'}, status=401) + + profile, _ = Profile.objects.get_or_create(user=user) + if not profile.totp_enabled or not profile.totp_secret: + return Response({'detail': 'Invalid credentials.'}, status=401) + + if code.isdigit() and len(code) == 6: + if not _verify_totp_with_replay_check(profile, code): + return Response({'detail': 'Invalid or already used code.'}, status=400) + else: + code_hash = hashlib.sha256(code.encode()).hexdigest() + backup = BackupCode.objects.filter(user=user, code_hash=code_hash, used=False).first() + if backup is None: + return Response({'detail': 'Invalid backup code.'}, status=400) + backup.used = True + backup.save(update_fields=['used']) + + refresh = RefreshToken.for_user(user) + session_key = _create_session(user, request, refresh) + return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key}) + + +class TwoFactorSetupView(views.APIView): + """Generates a fresh TOTP secret and returns the otpauth:// URI for QR display.""" + + def get(self, request): + profile, _ = Profile.objects.get_or_create(user=request.user) + secret = pyotp.random_base32() + profile.totp_secret = secret + profile.totp_enabled = False + profile.save(update_fields=['totp_secret', 'totp_enabled']) + email = request.user.email or request.user.username + uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name='Armarium') + return Response({'uri': uri}) + + +class TwoFactorEnableView(views.APIView): + """Verifies the first TOTP code, activates 2FA and returns one-time backup codes.""" + + def post(self, request): + code = str(request.data.get('code', '')).strip() + profile, _ = Profile.objects.get_or_create(user=request.user) + if not profile.totp_secret: + return Response({'detail': 'Run setup first.'}, status=400) + if not _verify_totp_with_replay_check(profile, code): + return Response({'detail': 'Invalid code.'}, status=400) + profile.totp_enabled = True + profile.save(update_fields=['totp_enabled']) + backup_codes = _generate_backup_codes(request.user) + return Response({'detail': '2FA enabled.', 'backup_codes': backup_codes}) + + +class TwoFactorDisableView(views.APIView): + """Disables 2FA — accepts TOTP code or a backup code as proof.""" + + def post(self, request): + code = str(request.data.get('code', '')).strip() + profile, _ = Profile.objects.get_or_create(user=request.user) + if not profile.totp_enabled: + return Response({'detail': '2FA is not enabled.'}, status=400) + + authenticated = False + if code.isdigit() and len(code) == 6: + authenticated = _verify_totp_with_replay_check(profile, code) + else: + code_hash = hashlib.sha256(code.encode()).hexdigest() + backup = BackupCode.objects.filter(user=request.user, code_hash=code_hash, used=False).first() + if backup: + backup.used = True + backup.save(update_fields=['used']) + authenticated = True + + if not authenticated: + return Response({'detail': 'Invalid code.'}, status=400) + + profile.totp_enabled = False + profile.totp_secret = '' + profile.totp_last_used_code = '' + profile.save(update_fields=['totp_enabled', 'totp_secret', 'totp_last_used_code']) + BackupCode.objects.filter(user=request.user).delete() + return Response({'detail': '2FA disabled.'}) + + +# ── Recovery email helpers ──────────────────────────────────────────────────── + +def _mask_email(email: str) -> str: + if '@' not in email: + return '***' + local, domain = email.split('@', 1) + return f"{local[0]}{'*' * min(len(local) - 1, 18)}@{domain}" + + +def _generate_recovery_code() -> str: + """Generate a human-readable 8-character code in XXXX-XXXX format.""" + alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # no O/0, I/1 confusion + part = lambda: ''.join(secrets.choice(alphabet) for _ in range(4)) + return f"{part()}-{part()}" + + +class TwoFactorRecoverRequestView(views.APIView): + """Generate a recovery code, store its hash in Profile and email the plain code.""" + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from django.utils import timezone + from datetime import timedelta + from .email import send_email + + temp_token = request.data.get('temp_token', '') + user_id = _verify_2fa_token(temp_token) + if user_id is None: + return Response({'detail': 'ok'}) + + User = get_user_model() + user = User.objects.filter(pk=user_id).first() + if not user: + return Response({'detail': 'ok'}) + + profile = Profile.objects.filter(user=user).first() + if not profile or not profile.recovery_email: + return Response({'detail': 'ok'}) + + plain_code = _generate_recovery_code() + profile.recovery_code_hash = hashlib.sha256(plain_code.encode()).hexdigest() + profile.recovery_code_expires = timezone.now() + timedelta(minutes=15) + profile.save(update_fields=['recovery_code_hash', 'recovery_code_expires']) + + sent = send_email( + template_name='2fa_recovery', + context={'code': plain_code}, + subject='Armarium – 2FA-Wiederherstellung', + to=profile.recovery_email, + ) + if not sent: + return Response({'detail': 'Failed to send recovery email.'}, status=500) + + return Response({'detail': 'ok', 'masked_email': _mask_email(profile.recovery_email)}) + + +class TwoFactorRecoverConfirmView(views.APIView): + """Verify the recovery code, disable 2FA and return JWT tokens.""" + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from django.utils import timezone + + temp_token = request.data.get('temp_token', '') + user_id = _verify_2fa_token(temp_token) + if user_id is None: + return Response({'detail': 'Session expired. Please log in again.'}, status=401) + + recovery_code = str(request.data.get('recovery_code', '')).strip().upper() + if not recovery_code: + return Response({'detail': 'Code required.'}, status=400) + + code_hash = hashlib.sha256(recovery_code.encode()).hexdigest() + profile = Profile.objects.filter( + user_id=user_id, + recovery_code_hash=code_hash, + recovery_code_expires__gt=timezone.now(), + ).first() + + if not profile: + return Response({'detail': 'Invalid or expired recovery code.'}, status=400) + + profile.totp_enabled = False + profile.totp_secret = '' + profile.totp_last_used_code = '' + profile.recovery_code_hash = '' + profile.recovery_code_expires = None + profile.save(update_fields=[ + 'totp_enabled', 'totp_secret', 'totp_last_used_code', + 'recovery_code_hash', 'recovery_code_expires', + ]) + BackupCode.objects.filter(user=profile.user).delete() + + refresh = RefreshToken.for_user(profile.user) + session_key = _create_session(profile.user, request, refresh) + return Response({'access': str(refresh.access_token), 'refresh': str(refresh), 'session_key': session_key}) + + +# ── Session helpers ─────────────────────────────────────────────────────────── + +def _parse_device(ua: str) -> str: + ua = ua.lower() + if 'iphone' in ua: return 'iPhone' + if 'ipad' in ua: return 'iPad' + if 'android' in ua and 'mobile' in ua: return 'Android (Phone)' + if 'android' in ua: return 'Android (Tablet)' + if 'macintosh' in ua or 'mac os x' in ua: return 'Mac' + if 'windows nt' in ua: return 'Windows' + if 'linux' in ua: return 'Linux' + return 'Unbekanntes Gerät' + + +def _get_client_ip(request) -> str | None: + forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '') + if forwarded: + return forwarded.split(',')[0].strip() + return request.META.get('REMOTE_ADDR') or None + + +def _create_session(user, request, refresh_token: RefreshToken) -> str: + session_key = secrets.token_urlsafe(32) + UserSession.objects.create( + user=user, + session_key=session_key, + refresh_jti=str(refresh_token.payload.get('jti', '')), + device_name=_parse_device(request.META.get('HTTP_USER_AGENT', '')), + ip_address=_get_client_ip(request), + ) + return session_key + + +# ── Session views ───────────────────────────────────────────────────────────── + +class SessionListView(views.APIView): + def get(self, request): + current_key = request.headers.get('X-Session-Key', '') + sessions = UserSession.objects.filter(user=request.user) + data = [ + { + 'session_key': s.session_key, + 'device_name': s.device_name, + 'ip_address': s.ip_address, + 'created_at': s.created_at, + 'last_active_at': s.last_active_at, + 'is_current': s.session_key == current_key, + } + for s in sessions + ] + return Response(data) + + +class SessionRevokeView(views.APIView): + def delete(self, request, session_key): + session = UserSession.objects.filter(user=request.user, session_key=session_key).first() + if not session: + return Response({'detail': 'Not found.'}, status=404) + _blacklist_session(session) + return Response(status=204) + + +class SessionRevokeAllView(views.APIView): + def delete(self, request): + current_key = request.headers.get('X-Session-Key', '') + sessions = UserSession.objects.filter(user=request.user).exclude(session_key=current_key) + for session in sessions: + _blacklist_session(session) + return Response(status=204) + + +def _blacklist_session(session: UserSession) -> None: + if session.refresh_jti: + try: + from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken + token = OutstandingToken.objects.get(jti=session.refresh_jti) + BlacklistedToken.objects.get_or_create(token=token) + except Exception: + pass + session.delete() + + +# ── Data export ─────────────────────────────────────────────────────────────── + +class DataExportView(views.APIView): + def get(self, request): + import io + import zipfile + from datetime import date + from fpdf import FPDF + + user = request.user + profile = Profile.objects.filter(user=user).first() + today = date.today().strftime('%d.%m.%Y') + export_date = date.today().strftime('%Y-%m-%d') + + VIOLET = (124, 58, 237) + HEADER_BG = (243, 240, 255) + ALT_ROW = (249, 249, 252) + TEXT_DARK = (30, 30, 40) + TEXT_GRAY = (120, 120, 135) + + def safe(text: str) -> str: + return str(text).encode('latin-1', errors='replace').decode('latin-1') + + class ArmPDF(FPDF): + def __init__(self, section_title): + super().__init__() + self.section_title = section_title + self.set_auto_page_break(auto=True, margin=18) + + def header(self): + self.set_fill_color(*VIOLET) + self.rect(0, 0, 210, 10, 'F') + self.set_xy(14, 13) + self.set_font('Helvetica', 'B', 15) + self.set_text_color(*TEXT_DARK) + self.cell(0, 7, safe(f'Armarium - {self.section_title}'), ln=True) + self.set_font('Helvetica', '', 8) + self.set_text_color(*TEXT_GRAY) + self.set_x(14) + self.cell(0, 5, safe(f'Export vom {today}'), ln=True) + self.ln(3) + + def footer(self): + self.set_y(-12) + self.set_font('Helvetica', '', 7) + self.set_text_color(*TEXT_GRAY) + self.cell(0, 5, safe(f'Armarium - {today} - Seite {self.page_no()}'), align='C') + + def table_header(self, cols): + self.set_fill_color(*HEADER_BG) + self.set_font('Helvetica', 'B', 8) + self.set_text_color(*VIOLET) + for label, width in cols: + self.cell(width, 7, safe(label), border=0, fill=True, align='L') + self.ln() + self.set_draw_color(*VIOLET) + self.set_line_width(0.4) + x = self.get_x() + y = self.get_y() + self.line(14, y, 196, y) + + def table_row(self, values, cols, fill=False): + if fill: + self.set_fill_color(*ALT_ROW) + self.set_font('Helvetica', '', 8) + self.set_text_color(*TEXT_DARK) + for (label, width), val in zip(cols, values): + self.cell(width, 6, safe(val), border=0, fill=fill) + self.ln() + + def make_pdf(title, build_fn): + pdf = ArmPDF(title) + pdf.add_page() + build_fn(pdf) + return pdf.output() + + # ── Profil ──────────────────────────────────────────────────────────── + def build_profile(pdf): + name = f"{profile.first_name} {profile.last_name}".strip() if profile else '' + rows = [ + ('Name', name or '-'), + ('E-Mail', user.email or '-'), + ('Kanton', profile.canton if profile else '-'), + ('Sprache', profile.language if profile else '-'), + ('2FA', 'Aktiviert' if (profile and profile.totp_enabled) else 'Deaktiviert'), + ] + cols = [('Feld', 60), ('Wert', 120)] + pdf.table_header(cols) + for i, (field, val) in enumerate(rows): + pdf.table_row([field, val], cols, fill=i % 2 == 1) + + # ── Konten ──────────────────────────────────────────────────────────── + def build_accounts(pdf): + cols = [('Name', 90), ('Typ', 60), ('Saldo (CHF)', 42)] + pdf.table_header(cols) + for i, acc in enumerate(Account.objects.filter(user=user)): + pdf.table_row([acc.name, acc.account_type, f'{acc.balance:,.2f}'], cols, fill=i % 2 == 1) + + # ── Budgets ─────────────────────────────────────────────────────────── + def build_budgets(pdf): + cols = [('Name', 80), ('Kategorie', 60), ('Betrag (CHF)', 42), ('Aktiv', 10)] + pdf.table_header(cols) + for i, b in enumerate(Budget.objects.filter(account__user=user).order_by('main_category', 'name')): + pdf.table_row([b.name, b.main_category, f'{b.amount:,.2f}', 'Ja' if b.active else 'Nein'], cols, fill=i % 2 == 1) + + # ── Ausgaben ────────────────────────────────────────────────────────── + def build_expenses(pdf): + cols = [('Datum', 26), ('Name', 70), ('Kategorie', 46), ('Konto', 30), ('CHF', 20)] + pdf.table_header(cols) + for i, e in enumerate(Expense.objects.filter(account__user=user).order_by('-date')): + pdf.table_row([ + e.date.strftime('%d.%m.%Y'), e.name, e.category, + e.account.name, f'{e.amount:,.2f}' + ], cols, fill=i % 2 == 1) + + # ── Transaktionen ───────────────────────────────────────────────────── + def build_transactions(pdf): + cols = [('Datum', 26), ('Beschreibung', 70), ('Von', 38), ('Nach', 38), ('CHF', 20)] + pdf.table_header(cols) + qs = Transaction.objects.filter(source_account__user=user).order_by('-date').select_related('source_account', 'destination_account') + for i, t in enumerate(qs): + pdf.table_row([ + t.date.strftime('%d.%m.%Y'), t.description, + t.source_account.name, t.destination_account.name, f'{t.amount:,.2f}' + ], cols, fill=i % 2 == 1) + + # ── Termine ─────────────────────────────────────────────────────────── + def build_deadlines(pdf): + cols = [('Datum', 30), ('Titel', 100), ('Typ', 42), ('Notizen', 20)] + pdf.table_header(cols) + for i, d in enumerate(Deadline.objects.filter(user=user).order_by('date')): + pdf.table_row([d.date.strftime('%d.%m.%Y'), d.title, d.type, d.notes[:20]], cols, fill=i % 2 == 1) + + pdfs = [ + ('profil.pdf', 'Profil', build_profile), + ('konten.pdf', 'Konten', build_accounts), + ('budgets.pdf', 'Budgets', build_budgets), + ('ausgaben.pdf', 'Ausgaben', build_expenses), + ('transaktionen.pdf', 'Transaktionen', build_transactions), + ('termine.pdf', 'Termine', build_deadlines), + ] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for filename, title, build_fn in pdfs: + zf.writestr(filename, bytes(make_pdf(title, build_fn))) + zip_buffer.seek(0) + + response = HttpResponse(zip_buffer.read(), content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename="armarium-export-{export_date}.zip"' + return response + + +# ── Notification preferences ────────────────────────────────────────────────── + +class NotificationPrefsView(views.APIView): + def patch(self, request): + profile, _ = Profile.objects.get_or_create(user=request.user) + fields = ['notif_deadlines', 'notif_budget_alerts', 'notif_monthly_summary'] + changed = [] + for field in fields: + if field in request.data: + setattr(profile, field, bool(request.data[field])) + changed.append(field) + if changed: + profile.save(update_fields=changed) + return Response({ + 'notif_deadlines': profile.notif_deadlines, + 'notif_budget_alerts': profile.notif_budget_alerts, + 'notif_monthly_summary': profile.notif_monthly_summary, + }) + + +class VerifyEmailView(views.APIView): + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from django.utils import timezone + + token = request.data.get('token', '').strip() + if not token: + return Response({'detail': 'Token required.'}, status=400) + token_hash = hashlib.sha256(token.encode()).hexdigest() + profile = Profile.objects.filter( + email_verify_token=token_hash, + email_verify_token_expires__gt=timezone.now(), + ).first() + if not profile: + return Response({'detail': 'Invalid or expired token.'}, status=400) + profile.email_verified = True + profile.email_verify_token = '' + profile.email_verify_token_expires = None + profile.save(update_fields=['email_verified', 'email_verify_token', 'email_verify_token_expires']) + return Response({'detail': 'Email verified.'}) + + +class PasswordResetRequestView(views.APIView): + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from django.utils import timezone + from datetime import timedelta + from .email import send_email + + email = request.data.get('email', '').strip().lower() + User = get_user_model() + user = User.objects.filter(email=email).first() + if user: + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode()).hexdigest() + profile, _ = Profile.objects.get_or_create(user=user) + profile.password_reset_token_hash = token_hash + profile.password_reset_token_expires = timezone.now() + timedelta(minutes=15) + profile.save(update_fields=['password_reset_token_hash', 'password_reset_token_expires']) + link = f"{settings.FRONTEND_URL}/reset-password?token={token}" + send_email('password_reset', {'link': link}, 'Armarium – Passwort zurücksetzen', user.email) + return Response({'detail': 'Wenn ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.'}) + + +class PasswordResetConfirmView(views.APIView): + permission_classes = [AllowAny] + throttle_classes = [AuthThrottle] + + def post(self, request): + from django.utils import timezone + + token = request.data.get('token', '').strip() + password = request.data.get('password', '') + if not token: + return Response({'detail': 'Token required.'}, status=400) + if len(password) < 8: + return Response({'detail': 'Password must be at least 8 characters.'}, status=400) + token_hash = hashlib.sha256(token.encode()).hexdigest() + profile = Profile.objects.filter( + password_reset_token_hash=token_hash, + password_reset_token_expires__gt=timezone.now(), + ).first() + if not profile: + return Response({'detail': 'Invalid or expired token.'}, status=400) + user = profile.user + user.set_password(password) + user.save() + profile.password_reset_token_hash = '' + profile.password_reset_token_expires = None + profile.save(update_fields=['password_reset_token_hash', 'password_reset_token_expires']) + for session in UserSession.objects.filter(user=user): + _blacklist_session(session) + return Response({'detail': 'Password updated.'}) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..f2a662c --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6d1cdb4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +asgiref==3.11.1 +Django==6.0.4 +django-cors-headers==4.9.0 +djangorestframework==3.17.1 +djangorestframework_simplejwt==5.5.1 +gunicorn==25.3.0 +icalendar==7.0.3 +packaging==26.0 +pillow==12.2.0 +psycopg2-binary==2.9.11 +PyJWT==2.12.1 +python-dateutil==2.9.0.post0 +pyotp==2.9.0 +python-dotenv==1.2.2 +six==1.17.0 +sqlparse==0.5.5 +typing_extensions==4.15.0 +tzdata==2026.1 +fpdf2==2.8.7 diff --git a/backend/templates/emails/2fa_recovery.html b/backend/templates/emails/2fa_recovery.html new file mode 100644 index 0000000..18ab031 --- /dev/null +++ b/backend/templates/emails/2fa_recovery.html @@ -0,0 +1,34 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – 2FA-Wiederherstellung{% endblock %} + +{% block body %} +

Hallo,

+ +

+ Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert. + Gib den folgenden Code auf der Anmeldeseite ein: +

+ + + + + + +
+ {{ code }} +
+ +

+ Gültig für 15 Minuten · Einmalig verwendbar +

+ +

+ Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. + Dein Konto ist weiterhin sicher. +

+ +

+ – Das Armarium-Team +

+{% endblock %} diff --git a/backend/templates/emails/2fa_recovery.txt b/backend/templates/emails/2fa_recovery.txt new file mode 100644 index 0000000..bd34554 --- /dev/null +++ b/backend/templates/emails/2fa_recovery.txt @@ -0,0 +1,18 @@ +Armarium – 2FA-Wiederherstellung + +Hallo, + +Du hast eine 2FA-Wiederherstellung für dein Armarium-Konto angefordert. + +Dein Wiederherstellungscode lautet: + + {{ code }} + +Gib diesen Code auf der Anmeldeseite ein. +Er ist 15 Minuten gültig und kann nur einmal verwendet werden. + +Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. +Dein Konto ist weiterhin sicher. + +– Das Armarium-Team +https://www.armarium.ch diff --git a/backend/templates/emails/base.html b/backend/templates/emails/base.html new file mode 100644 index 0000000..f1d85a7 --- /dev/null +++ b/backend/templates/emails/base.html @@ -0,0 +1,46 @@ + + + + + + {% block subject %}Armarium{% endblock %} + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ Armarium +
+ {% block body %}{% endblock %} +
+

+ Du erhältst diese E-Mail, weil du ein Konto bei + armarium.ch hast. +
Falls du diese E-Mail nicht erwartet hast, kannst du sie ignorieren. +

+
+
+ + + diff --git a/backend/templates/emails/email_changed.html b/backend/templates/emails/email_changed.html new file mode 100644 index 0000000..17dce51 --- /dev/null +++ b/backend/templates/emails/email_changed.html @@ -0,0 +1,33 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – Deine E-Mail-Adresse wurde geändert{% endblock %} + +{% block body %} +

Hallo,

+ +

+ Die E-Mail-Adresse deines Armarium-Kontos wurde geändert. +

+ + + + + +
+

Neue Adresse

+

{{ new_email }}

+
+ + + + + +
+

+ Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter + support@armarium.ch. +

+
+ +

– Das Armarium-Team

+{% endblock %} diff --git a/backend/templates/emails/email_changed.txt b/backend/templates/emails/email_changed.txt new file mode 100644 index 0000000..b44453c --- /dev/null +++ b/backend/templates/emails/email_changed.txt @@ -0,0 +1,12 @@ +Armarium – Deine E-Mail-Adresse wurde geändert + +Hallo, + +Die E-Mail-Adresse deines Armarium-Kontos wurde geändert. + +Neue Adresse: {{ new_email }} + +Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch. + +– Das Armarium-Team +https://www.armarium.ch diff --git a/backend/templates/emails/password_changed.html b/backend/templates/emails/password_changed.html new file mode 100644 index 0000000..485191c --- /dev/null +++ b/backend/templates/emails/password_changed.html @@ -0,0 +1,24 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – Dein Passwort wurde geändert{% endblock %} + +{% block body %} +

Hallo,

+ +

+ Dein Armarium-Passwort wurde erfolgreich geändert. +

+ + + + + +
+

+ Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter + support@armarium.ch. +

+
+ +

– Das Armarium-Team

+{% endblock %} diff --git a/backend/templates/emails/password_changed.txt b/backend/templates/emails/password_changed.txt new file mode 100644 index 0000000..0db97a4 --- /dev/null +++ b/backend/templates/emails/password_changed.txt @@ -0,0 +1,10 @@ +Armarium – Dein Passwort wurde geändert + +Hallo, + +Dein Armarium-Passwort wurde erfolgreich geändert. + +Falls du diese Änderung nicht selbst vorgenommen hast, kontaktiere uns umgehend unter support@armarium.ch. + +– Das Armarium-Team +https://www.armarium.ch diff --git a/backend/templates/emails/password_reset.html b/backend/templates/emails/password_reset.html new file mode 100644 index 0000000..2aedd81 --- /dev/null +++ b/backend/templates/emails/password_reset.html @@ -0,0 +1,35 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – Passwort zurücksetzen{% endblock %} + +{% block body %} +

Hallo,

+ +

+ Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. + Klicke auf den Button, um ein neues Passwort zu wählen: +

+ + + + + +
+ + Neues Passwort setzen + +
+ +

+ Gültig für 15 Minuten. Falls der Button nicht funktioniert, kopiere diesen Link: +

+

{{ link }}

+ +

+ Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. + Dein Passwort bleibt unverändert. +

+ +

– Das Armarium-Team

+{% endblock %} diff --git a/backend/templates/emails/password_reset.txt b/backend/templates/emails/password_reset.txt new file mode 100644 index 0000000..a98cc79 --- /dev/null +++ b/backend/templates/emails/password_reset.txt @@ -0,0 +1,16 @@ +Armarium – Passwort zurücksetzen + +Hallo, + +Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. + +Link zum Zurücksetzen: +{{ link }} + +Gültig für 15 Minuten. + +Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. +Dein Passwort bleibt unverändert. + +– Das Armarium-Team +https://www.armarium.ch diff --git a/backend/templates/emails/registration_confirm.html b/backend/templates/emails/registration_confirm.html new file mode 100644 index 0000000..4de01a7 --- /dev/null +++ b/backend/templates/emails/registration_confirm.html @@ -0,0 +1,33 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – E-Mail-Adresse bestätigen{% endblock %} + +{% block body %} +

Hallo,

+ +

+ Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren. +

+ + + + + +
+ + E-Mail-Adresse bestätigen + +
+ +

+ Gültig für 24 Stunden. Falls der Button nicht funktioniert, kopiere diesen Link: +

+

{{ link }}

+ +

+ Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren. +

+ +

– Das Armarium-Team

+{% endblock %} diff --git a/backend/templates/emails/registration_confirm.txt b/backend/templates/emails/registration_confirm.txt new file mode 100644 index 0000000..35e4a85 --- /dev/null +++ b/backend/templates/emails/registration_confirm.txt @@ -0,0 +1,15 @@ +Armarium – E-Mail-Adresse bestätigen + +Hallo, + +Willkommen bei Armarium! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren. + +Link zur Bestätigung: +{{ link }} + +Gültig für 24 Stunden. + +Falls du dieses Konto nicht erstellt hast, kannst du diese E-Mail ignorieren. + +– Das Armarium-Team +https://www.armarium.ch diff --git a/frontend b/frontend deleted file mode 160000 index e38e987..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e38e9877c0ceaca4b9e32bc855c6601ce4ba4f5d diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/frontend/.vscode/mcp.json b/frontend/.vscode/mcp.json new file mode 100644 index 0000000..956af8c --- /dev/null +++ b/frontend/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + // For more information, visit: https://angular.dev/ai/mcp + "servers": { + "angular-cli": { + "command": "npx", + "args": ["-y", "@angular/cli", "mcp"] + } + } +} diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json new file mode 100644 index 0000000..244306f --- /dev/null +++ b/frontend/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d90e994 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,59 @@ +# BudgetFrontend + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..9ba1db5 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,82 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm", + "analytics": "97da586f-bf37-4b8e-beae-24c4d04eb25b" + }, + "newProjectRoot": "projects", + "projects": { + "budget-frontend": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + }, + { + "glob": "**/*", + "input": "src/assets", + "output": "assets" + } + ], + "styles": [ + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "1MB", + "maximumError": "2MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, + "configurations": { + "production": { + "buildTarget": "budget-frontend:build:production" + }, + "development": { + "buildTarget": "budget-frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..02bd231 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,10713 @@ +{ + "name": "budget-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-frontend", + "version": "0.0.0", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "@fontsource/roboto": "^5.2.10", + "@ngx-translate/core": "^17.0.0", + "@ngx-translate/http-loader": "^17.0.0", + "@types/qrcode": "^1.5.6", + "apexcharts": "^3.46.0", + "flowbite": "^4.0.1", + "flowbite-icons": "^1.5.0", + "jspdf": "^4.2.1", + "qrcode": "^1.5.4", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^21.2.1", + "@angular/cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.0", + "autoprefixer": "^10.4.27", + "jsdom": "^28.0.0", + "postcss": "^8.5.8", + "prettier": "^3.8.1", + "tailwindcss": "^3.4.19", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@algolia/abtesting": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", + "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", + "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", + "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", + "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", + "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", + "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", + "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", + "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", + "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", + "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", + "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", + "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", + "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", + "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2102.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz", + "integrity": "sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz", + "integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.3", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz", + "integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.3.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.1.tgz", + "integrity": "sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2102.1", + "@babel/core": "7.29.0", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "5.1.21", + "@vitejs/plugin-basic-ssl": "2.1.4", + "beasties": "0.4.1", + "browserslist": "^4.26.0", + "esbuild": "0.27.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "magic-string": "0.30.21", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.0", + "picomatch": "4.0.3", + "piscina": "5.1.4", + "rolldown": "1.0.0-rc.4", + "sass": "1.97.3", + "semver": "7.7.4", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.15", + "undici": "7.22.0", + "vite": "7.3.1", + "watchpack": "2.5.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.1" + }, + "peerDependencies": { + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/localize": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "@angular/service-worker": "^21.0.0", + "@angular/ssr": "^21.2.1", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^21.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.9 <6.0", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-5SRfMTgwFj1zXOpfeZWHsxZBni0J4Xz7/CbewG47D6DmbstOrSdgt6eNzJ62R650t0G9dpri2YvToZgImtbjOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2102.1", + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", + "@modelcontextprotocol/sdk": "1.26.0", + "@schematics/angular": "21.2.1", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.3.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.3.6" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.1.tgz", + "integrity": "sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.1.tgz", + "integrity": "sha512-FxWaSaii1vfHIFA+JksqQ8NGB2frfqCrs7Ju50a44kbwR4fmanfn/VsiS/CbwBp9vcyT/Br9X/jAG4RuK/U2nw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.1.tgz", + "integrity": "sha512-qYCWLGtEju4cDtYLi4ZzbwKoF0lcGs+Lc31kuESvAzYvWNgk2EUOtwWo8kbgpAzAwSYodtxW6Q90iWEwfU6elw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.29.0", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.1", + "typescript": ">=5.9 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.1.tgz", + "integrity": "sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.1", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.1.tgz", + "integrity": "sha512-6aqOPk9xoa0dfeUDeEbhaiPhmt6MQrdn59qbGAomn9RMXA925TrHbJhSIkp9tXc2Fr4aJRi8zkD/cdXEc1IYeA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.1", + "@angular/core": "21.2.1", + "@angular/platform-browser": "21.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.1.tgz", + "integrity": "sha512-k4SJLxIaLT26vLjLuFL+ho0BiG5PrdxEsjsXFC7w5iUhomeouzkHVTZ4t7gaLNKrdRD7QNtU4Faw0nL0yx0ZPQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.1", + "@angular/common": "21.2.1", + "@angular/core": "21.2.1" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.1.tgz", + "integrity": "sha512-FUKG+8ImQYxmlDUdAs7+VeS/VrBNrbo0zGiKkzVNU/bbcCyroKXJLXFtkFI3qmROiJNyIta2IMBCHJvIjLIMig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.1", + "@angular/core": "21.2.1", + "@angular/platform-browser": "21.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "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.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "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/core/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, + "license": "MIT" + }, + "node_modules/@babel/core/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, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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, + "license": "MIT", + "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-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "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, + "license": "MIT", + "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-compilation-targets/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, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/roboto": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", + "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", + "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8", + "listr2": "9.0.5" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", + "integrity": "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.1.tgz", + "integrity": "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.1.tgz", + "integrity": "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.1.tgz", + "integrity": "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.1.tgz", + "integrity": "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.1.tgz", + "integrity": "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.1.tgz", + "integrity": "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@ngx-translate/core": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz", + "integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=16", + "@angular/core": ">=16" + } + }, + "node_modules/@ngx-translate/http-loader": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-17.0.0.tgz", + "integrity": "sha512-hgS8sa0ARjH9ll3PhkLTufeVXNI2DNR2uFKDhBgq13siUXzzVr/a31M6zgecrtwbA34iaBV01hsTMbMS8V7iIw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=16", + "@angular/core": ">=16" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.113.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", + "integrity": "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.4.tgz", + "integrity": "sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.4.tgz", + "integrity": "sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz", + "integrity": "sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz", + "integrity": "sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.1.0.tgz", + "integrity": "sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", + "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.0.tgz", + "integrity": "sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.3", + "proc-log": "^6.1.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.1.tgz", + "integrity": "sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", + "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/algoliasearch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", + "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.14.1", + "@algolia/client-abtesting": "5.48.1", + "@algolia/client-analytics": "5.48.1", + "@algolia/client-common": "5.48.1", + "@algolia/client-insights": "5.48.1", + "@algolia/client-personalization": "5.48.1", + "@algolia/client-query-suggestions": "5.48.1", + "@algolia/client-search": "5.48.1", + "@algolia/ingestion": "1.48.1", + "@algolia/monitoring": "1.48.1", + "@algolia/recommend": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/apexcharts": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.46.0.tgz", + "integrity": "sha512-ELAY6vj8JQD7QLktKasTzwm9Wt0qxqfQSo+3QWS7G7I774iK8HCkG1toGsqJH0mkK6PtYBtnSIe66uUcwoCw1w==", + "license": "MIT", + "dependencies": { + "@yr/monotone-cubic-spline": "^1.0.3", + "svg.draggable.js": "^2.2.2", + "svg.easing.js": "^2.0.0", + "svg.filter.js": "^2.0.2", + "svg.pathmorphing.js": "^0.1.3", + "svg.resize.js": "^1.4.3", + "svg.select.js": "^3.0.1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "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" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", + "integrity": "sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "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" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "dev": true, + "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" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz", + "integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flowbite": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-4.0.1.tgz", + "integrity": "sha512-UwUjvnqrQTiFm3uMJ0WWnzKXKoDyNyfyEzoNnxmZo6KyDzCedjqZw1UW0Oqdn+E0iYVdPu0fizydJN6e4pP9Rw==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^2.0.0", + "mini-svg-data-uri": "^1.4.3", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.12" + } + }, + "node_modules/flowbite-datepicker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-2.0.0.tgz", + "integrity": "sha512-m81hl0Bimq45MUg4maJLOnXrX+C9lZ0AkjMb9uotuVUSr729k/YiymWDfVAm63AYDH7g7y3rI3ke3XaBzWWqLw==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "@tailwindcss/postcss": "^4.1.17" + } + }, + "node_modules/flowbite-icons": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/flowbite-icons/-/flowbite-icons-1.5.0.tgz", + "integrity": "sha512-yXU7g6Bhvhtjpg/EPMCKpo/WTitX44oQVTgH/B8oki5Pt06D/wJLAHTycAom73LYVkf8UiNa8oPrxlJQkw5gpg==", + "license": "MIT" + }, + "node_modules/flowbite/node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "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, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", + "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "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, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lmdb": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", + "integrity": "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.1", + "@lmdb/lmdb-darwin-x64": "3.5.1", + "@lmdb/lmdb-linux-arm": "3.5.1", + "@lmdb/lmdb-linux-arm64": "3.5.1", + "@lmdb/lmdb-linux-x64": "3.5.1", + "@lmdb/lmdb-win32-arm64": "3.5.1", + "@lmdb/lmdb-win32-x64": "3.5.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=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, + "license": "ISC", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", + "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pacote": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", + "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", + "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/piscina": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", + "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "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-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "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/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.4.tgz", + "integrity": "sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.113.0", + "@rolldown/pluginutils": "1.0.0-rc.4" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-x64": "1.0.0-rc.4", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.4", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.4", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.4", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.4", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "license": "ISC" + }, + "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, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", + "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.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, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/svg.draggable.js": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", + "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.easing.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz", + "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==", + "license": "MIT", + "dependencies": { + "svg.js": ">=2.3.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.filter.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz", + "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz", + "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==", + "license": "MIT" + }, + "node_modules/svg.pathmorphing.js": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz", + "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz", + "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.6.5", + "svg.select.js": "^2.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js/node_modules/svg.select.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", + "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.select.js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz", + "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==", + "license": "MIT", + "dependencies": { + "svg.js": "^2.6.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "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/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "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/tailwindcss/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/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==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unique-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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" + } + ], + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "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, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8f23888 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "budget-frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "packageManager": "npm@11.6.2", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "@fontsource/roboto": "^5.2.10", + "@ngx-translate/core": "^17.0.0", + "@ngx-translate/http-loader": "^17.0.0", + "@types/qrcode": "^1.5.6", + "apexcharts": "^3.46.0", + "jspdf": "^4.2.1", + "qrcode": "^1.5.4", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^21.2.1", + "@angular/cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.0", + "autoprefixer": "^10.4.27", + "jsdom": "^28.0.0", + "postcss": "^8.5.8", + "prettier": "^3.8.1", + "tailwindcss": "^3.4.19", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e4d62c8 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..29af5bf --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,10 @@ +{ + "/api": { + "target": "http://localhost:8000", + "secure": false + }, + "/media": { + "target": "http://localhost:8000", + "secure": false + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/frontend/src/app/accounts/account-list/account-list.css b/frontend/src/app/accounts/account-list/account-list.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/accounts/account-list/account-list.html b/frontend/src/app/accounts/account-list/account-list.html new file mode 100644 index 0000000..628fb7b --- /dev/null +++ b/frontend/src/app/accounts/account-list/account-list.html @@ -0,0 +1,227 @@ + +
+
+

{{ 'accounts.title' | translate }}

+
+ +
+ + +
+
+ + + + + + + + + + + @for (account of accounts(); track account.id) { + + + + + + + } @empty { + + + + } + +
{{ 'common.name' | translate }}{{ 'accounts.col_type' | translate }}{{ 'accounts.col_balance' | translate }}Actions
+ {{ account.name }} + + @if (account.account_type === 'asset') { + + {{ 'accounts.type_asset' | translate }} + + } @else { + + {{ 'accounts.type_revenue' | translate }} + + } + + {{ account.balance | number:'1.2-2' }} CHF + +
+ + +
+
+ {{ 'accounts.no_accounts' | translate }} +
+
+
+ + + +@if (showCreateModal()) { +
+
+
+
+ + +
+

{{ 'accounts.create_title' | translate }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showEditModal()) { +
+
+
+
+ + +
+

{{ 'accounts.edit_title' | translate }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showDeleteModal()) { +
+
+
+
+ +
+ + + + +
+ +

{{ 'common.delete_confirm_title' | translate }}

+

{{ 'common.delete_confirm_text' | translate }}

+ +
+ + +
+ +
+
+
+} diff --git a/frontend/src/app/accounts/account-list/account-list.spec.ts b/frontend/src/app/accounts/account-list/account-list.spec.ts new file mode 100644 index 0000000..b248821 --- /dev/null +++ b/frontend/src/app/accounts/account-list/account-list.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccountList } from './account-list'; + +describe('AccountList', () => { + let component: AccountList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccountList], + }).compileComponents(); + + fixture = TestBed.createComponent(AccountList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/accounts/account-list/account-list.ts b/frontend/src/app/accounts/account-list/account-list.ts new file mode 100644 index 0000000..32bf2f5 --- /dev/null +++ b/frontend/src/app/accounts/account-list/account-list.ts @@ -0,0 +1,122 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; + +@Component({ + selector: 'app-account-list', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './account-list.html', + styleUrl: './account-list.css', +}) +export class AccountList implements OnInit { + accounts = signal([]); + + // Create Modal + showCreateModal = signal(false); + newName = ''; + newBalance = 0; + newType = 'asset'; + + // Edit Modal + showEditModal = signal(false); + editId = 0; + + // Delete Modal + showDeleteModal = signal(false); + deleteTargetId = 0; + editName = ''; + editBalance = 0; + editType = 'asset'; + + constructor(private api: ApiService) {} + + ngOnInit(): void { + this.loadAccounts(); + } + + loadAccounts() { + this.api.getAccounts().subscribe({ + next: (data) => this.accounts.set(data.filter((a: any) => a.account_type === 'asset' || a.account_type === 'revenue')), + error: (err) => console.error('Fehler:', err) + }); + } + + // Create + openCreateModal() { + this.showCreateModal.set(true); + } + + closeCreateModal() { + this.showCreateModal.set(false); + this.newName = ''; + this.newBalance = 0; + this.newType = 'asset'; + } + + createAccount() { + if (!this.newName) return; + this.api.createAccount({ + name: this.newName, + balance: this.newBalance, + account_type: this.newType + }).subscribe({ + next: () => { + this.loadAccounts(); + this.closeCreateModal(); + }, + error: (err) => console.error('Fehler beim Erstellen:', err) + }); + } + + // Edit + openEditModal(account: any) { + this.editId = account.id; + this.editName = account.name; + this.editBalance = account.balance; + this.editType = account.account_type; + this.showEditModal.set(true); + } + + closeEditModal() { + this.showEditModal.set(false); + } + + updateAccount() { + if (!this.editName) return; + this.api.updateAccount(this.editId, { + name: this.editName, + balance: this.editBalance, + account_type: this.editType + }).subscribe({ + next: () => { + this.loadAccounts(); + this.closeEditModal(); + }, + error: (err) => console.error('Fehler beim Bearbeiten:', err) + }); + } + + // Delete + openDeleteModal(id: number) { + this.deleteTargetId = id; + this.showDeleteModal.set(true); + } + + closeDeleteModal() { + this.showDeleteModal.set(false); + this.deleteTargetId = 0; + } + + confirmDelete() { + this.api.deleteAccount(this.deleteTargetId).subscribe({ + next: () => { + this.loadAccounts(); + this.closeDeleteModal(); + }, + error: (err) => console.error('Error deleting account:', err) + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..aa042a0 --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,44 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, importProvidersFrom, APP_INITIALIZER } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core'; +import { TranslateHttpLoader, TRANSLATE_HTTP_LOADER_CONFIG } from '@ngx-translate/http-loader'; +import { routes } from './app.routes'; +import { authInterceptor } from './interceptors/auth.interceptor'; + +const SUPPORTED_LANGS = ['de', 'fr', 'it', 'en']; + +function preloadTranslations(translate: TranslateService): () => Promise { + return () => { + const stored = localStorage.getItem('app_language'); + const browser = navigator.language?.split('-')[0].toLowerCase(); + const lang = SUPPORTED_LANGS.includes(stored ?? '') ? stored! + : SUPPORTED_LANGS.includes(browser) ? browser + : 'de'; + translate.setDefaultLang('de'); + return translate.use(lang).toPromise(); + }; +} + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + importProvidersFrom( + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateHttpLoader, + }, + }) + ), + { provide: TRANSLATE_HTTP_LOADER_CONFIG, useValue: { prefix: '/assets/i18n/', suffix: '.json' } }, + { + provide: APP_INITIALIZER, + useFactory: preloadTranslations, + deps: [TranslateService], + multi: true, + }, + ] +}; diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html new file mode 100644 index 0000000..67e7bd4 --- /dev/null +++ b/frontend/src/app/app.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..fb9ce80 --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,40 @@ +import { Routes } from '@angular/router'; +import { authGuard } from './guards/auth.guard'; +import { Shell } from './layout/shell/shell'; +import { Login } from './auth/login/login'; +import { Register } from './auth/register/register'; +import { ForgotPassword } from './auth/forgot-password/forgot-password'; +import { ResetPassword } from './auth/reset-password/reset-password'; +import { VerifyEmail } from './auth/verify-email/verify-email'; +import { Dashboard } from './dashboard/dashboard'; +import { AccountList } from './accounts/account-list/account-list'; +import { Budgets } from './budgets/budgets'; +import { TransactionList } from './transactions/transaction-list/transaction-list'; +import { ExpenseList } from './expenses/expense-list/expense-list'; +import { Profile } from './profile/profile'; +import { Settings } from './settings/settings'; +import { Calendar } from './calendar/calendar'; +export const routes: Routes = [ + { path: 'login', component: Login }, + { path: 'register', component: Register }, + { path: 'forgot-password', component: ForgotPassword }, + { path: 'reset-password', component: ResetPassword }, + { path: 'verify-email', component: VerifyEmail }, + { + path: '', + component: Shell, + canActivate: [authGuard], + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', component: Dashboard }, + { path: 'accounts', component: AccountList }, + { path: 'budgets', component: Budgets }, + { path: 'expenses', component: ExpenseList }, + { path: 'transactions', component: TransactionList }, + { path: 'profile', component: Profile }, + { path: 'settings', component: Settings }, + { path: 'calendar', component: Calendar }, + ], + }, + { path: '**', redirectTo: 'dashboard' }, +]; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts new file mode 100644 index 0000000..b5121a4 --- /dev/null +++ b/frontend/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, budget-frontend'); + }); +}); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts new file mode 100644 index 0000000..c1799d1 --- /dev/null +++ b/frontend/src/app/app.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: '', +}) +export class App {} diff --git a/frontend/src/app/auth/forgot-password/forgot-password.html b/frontend/src/app/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..259ef1a --- /dev/null +++ b/frontend/src/app/auth/forgot-password/forgot-password.html @@ -0,0 +1,96 @@ +
+
+ + +
+ Armarium +

{{ 'auth.forgot_password_tagline' | translate }}

+
+ + +
+ + +
+ + +
+ + @if (!sent()) { + +

+ {{ 'auth.forgot_password' | translate }} +

+

+ {{ 'auth.forgot_password_hint' | translate }} +

+ + +
+ + +
+ + + @if (error()) { +
+ + + + {{ error() | translate }} +
+ } + + + + + } @else { + +
+
+ + + +
+

{{ 'auth.reset_link_sent' | translate }}

+

{{ 'auth.reset_link_sent_hint' | translate }}

+
+ } + + +

+ + {{ 'auth.back_to_login' | translate }} + +

+ +
+
+
diff --git a/frontend/src/app/auth/forgot-password/forgot-password.ts b/frontend/src/app/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..03347b1 --- /dev/null +++ b/frontend/src/app/auth/forgot-password/forgot-password.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; +import { LanguageService } from '../../services/language'; +import { ThemeService } from '../../services/theme'; +import { LangSwitcher } from '../lang-switcher/lang-switcher'; +import { signal } from '@angular/core'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher], + templateUrl: './forgot-password.html', +}) +export class ForgotPassword { + email = ''; + loading = signal(false); + sent = signal(false); + error = signal(''); + + constructor( + private api: ApiService, + private langService: LanguageService, + public themeService: ThemeService, + ) { + this.langService.init(); + } + + submit(): void { + this.error.set(''); + if (!this.email.trim()) { + this.error.set('auth.errors.fields_required'); + return; + } + this.loading.set(true); + this.api.requestPasswordReset(this.email.trim()).subscribe({ + next: () => { + this.sent.set(true); + this.loading.set(false); + }, + error: () => { + this.sent.set(true); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/auth/lang-switcher/lang-switcher.ts b/frontend/src/app/auth/lang-switcher/lang-switcher.ts new file mode 100644 index 0000000..c9aca52 --- /dev/null +++ b/frontend/src/app/auth/lang-switcher/lang-switcher.ts @@ -0,0 +1,74 @@ +import { Component, signal, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LanguageService } from '../../services/language'; + +const LANGS = [ + { code: 'de', label: 'Deutsch' }, + { code: 'fr', label: 'Français' }, + { code: 'it', label: 'Italiano' }, + { code: 'en', label: 'English' }, +]; + +@Component({ + selector: 'app-lang-switcher', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + @if (open()) { +
+ @for (lang of langs; track lang.code) { + + } +
+ } +
+ `, +}) +export class LangSwitcher { + protected readonly langs = LANGS; + protected readonly open = signal(false); + protected readonly current = signal('de'); + + constructor(private langService: LanguageService) { + this.current.set(langService.current); + } + + protected itemClass(code: string): string { + return code === this.current() + ? 'font-semibold text-violet-600 bg-violet-50' + : 'text-gray-700 hover:bg-gray-50'; + } + + protected select(code: string): void { + this.langService.setLanguage(code); + this.current.set(code); + this.open.set(false); + } + + @HostListener('document:click', ['$event']) + onClickOutside(event: MouseEvent): void { + if (!(event.target as Element).closest('app-lang-switcher')) { + this.open.set(false); + } + } +} diff --git a/frontend/src/app/auth/login/login.html b/frontend/src/app/auth/login/login.html new file mode 100644 index 0000000..068ccf1 --- /dev/null +++ b/frontend/src/app/auth/login/login.html @@ -0,0 +1,266 @@ +
+
+ + +
+ Armarium +

{{ 'auth.tagline_login' | translate }}

+
+ + +
+ + +
+ + +
+ + + @if (step() === 'credentials') { +
+
+ + +
+
+ +
+ + +
+
+ +
+
+ +
+
+ {{ 'auth.keep_signed_in' | translate }} + {{ 'auth.keep_signed_in_hint' | translate }} +
+
+ + @if (error()) { +

{{ error() | translate }}

+ } + + + + + +

+ {{ 'auth.forgot_password' | translate }} +

+

+ {{ 'auth.no_account' | translate }} + {{ 'auth.sign_up' | translate }} +

+
+ } + + + @if (step() === 'totp') { +
+
+
+ + + + +
+
+

{{ 'auth.totp_title' | translate }}

+

{{ 'auth.totp_hint' | translate }}

+
+ +
+ + + + + {{ totpCountdown() }} +
+
+ + +
+ @for (i of [0,1,2,3,4,5]; track i) { + + } +
+ + @if (error()) { +

{{ error() | translate }}

+ } + @if (loading()) { +

{{ 'auth.signing_in' | translate }}

+ } + +

+ {{ 'auth.totp_no_device' | translate }} + +

+
+ + + } + + + @if (step() === 'backup') { +
+
+
+ + + + +
+
+

{{ 'auth.totp_use_backup' | translate }}

+

{{ 'auth.backup_format_hint' | translate }}

+
+
+ + + + @if (error()) { +

{{ error() | translate }}

+ } + + + +

+ {{ 'auth.totp_no_backup' | translate }} + +

+
+ + + } + + + @if (step() === 'recovery') { +
+
+
+ + + + +
+
+

{{ 'auth.recovery_title' | translate }}

+

{{ 'auth.recovery_intro' | translate }}

+
+
+ + @if (!recoverySent()) { + + } @else { +
+

{{ 'auth.recovery_sent' | translate }}

+ @if (maskedEmail()) { +

{{ maskedEmail() }}

+ } +

{{ 'auth.recovery_spam_hint' | translate }}

+
+ + + + @if (error()) { +

{{ error() | translate }}

+ } + + + } +
+ + + } + +
+
+
diff --git a/frontend/src/app/auth/login/login.ts b/frontend/src/app/auth/login/login.ts new file mode 100644 index 0000000..24a10f9 --- /dev/null +++ b/frontend/src/app/auth/login/login.ts @@ -0,0 +1,249 @@ +import { Component, OnInit, OnDestroy, ViewChild, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from '../../services/auth'; +import { ApiService } from '../../services/api'; +import { LanguageService } from '../../services/language'; +import { ThemeService } from '../../services/theme'; +import { LangSwitcher } from '../lang-switcher/lang-switcher'; +import { TurnstileComponent } from '../turnstile/turnstile'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent], + templateUrl: './login.html', +}) +export class Login implements OnInit, OnDestroy { + @ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent; + turnstileToken = ''; + email = ''; + password = ''; + keepSignedIn = true; + showPassword = signal(false); + error = signal(''); + loading = signal(false); + step = signal<'credentials' | 'totp' | 'backup' | 'recovery'>('credentials'); + + // TOTP digit inputs + digits: string[] = ['', '', '', '', '', '']; + + // Backup code + backupCode = ''; + + // Recovery + recoverySent = signal(false); + maskedEmail = signal(''); + recoveryCode = ''; + + // Countdown + totpCountdown = signal(30); + + private pendingTempToken = ''; + private countdownInterval?: ReturnType; + + constructor( + private auth: AuthService, + private api: ApiService, + private router: Router, + private langService: LanguageService, + public themeService: ThemeService, + ) {} + + ngOnInit(): void { + this.langService.init(); + } + + ngOnDestroy(): void { + clearInterval(this.countdownInterval); + } + + // ── Credentials step ──────────────────────────────────────────────────────── + + submit(): void { + this.error.set(''); + if (!this.email || !this.password) { + this.error.set('auth.errors.enter_credentials'); + return; + } + this.loading.set(true); + this.auth.login(this.email, this.password, this.keepSignedIn, this.turnstileToken).subscribe({ + next: (res) => { + if (res['2fa_required']) { + this.pendingTempToken = res.temp_token; + this.step.set('totp'); + this.loading.set(false); + this.startCountdown(); + setTimeout(() => this.focusDigit(0), 50); + } else { + this.router.navigate(['/dashboard']); + } + }, + error: (err) => { + this.error.set(err.status === 400 ? 'auth.errors.captcha_failed' : 'auth.errors.invalid_credentials'); + this.turnstileToken = ''; + this.turnstileComp?.reset(); + this.loading.set(false); + }, + }); + } + + // ── TOTP digit input handling ──────────────────────────────────────────────── + + onDigitInput(index: number, event: Event): void { + const input = event.target as HTMLInputElement; + const val = input.value.replace(/\D/g, '').slice(-1); + this.digits[index] = val; + input.value = val; + if (val) { + if (index < 5) { + this.focusDigit(index + 1); + } else if (this.digits.every(d => d !== '')) { + this.submitTotp(this.digits.join('')); + } + } + } + + onDigitKeydown(index: number, event: KeyboardEvent): void { + if (event.key === 'Backspace' && !this.digits[index] && index > 0) { + this.digits[index - 1] = ''; + const prev = this.getDigitInput(index - 1); + if (prev) { prev.value = ''; prev.focus(); } + } + if (event.key === 'ArrowLeft' && index > 0) this.focusDigit(index - 1); + if (event.key === 'ArrowRight' && index < 5) this.focusDigit(index + 1); + } + + onDigitPaste(event: ClipboardEvent): void { + event.preventDefault(); + const text = event.clipboardData?.getData('text') ?? ''; + const nums = text.replace(/\D/g, '').slice(0, 6); + for (let i = 0; i < 6; i++) { + this.digits[i] = nums[i] ?? ''; + const el = this.getDigitInput(i); + if (el) el.value = this.digits[i]; + } + const next = Math.min(nums.length, 5); + this.focusDigit(next); + if (nums.length === 6) this.submitTotp(nums); + } + + private focusDigit(index: number): void { + this.getDigitInput(index)?.focus(); + } + + private getDigitInput(index: number): HTMLInputElement | null { + return document.querySelectorAll('.otp-digit')[index] ?? null; + } + + // ── TOTP submit ────────────────────────────────────────────────────────────── + + private submitTotp(code: string): void { + this.error.set(''); + if (code.length !== 6) return; + this.loading.set(true); + this.api.login2FA(this.pendingTempToken, code).subscribe({ + next: (tokens) => { + clearInterval(this.countdownInterval); + this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key); + this.router.navigate(['/dashboard']); + }, + error: () => { + this.error.set('auth.errors.invalid_totp'); + this.loading.set(false); + this.digits = ['', '', '', '', '', '']; + document.querySelectorAll('.otp-digit').forEach(el => el.value = ''); + setTimeout(() => this.focusDigit(0), 50); + }, + }); + } + + // ── Backup code submit ─────────────────────────────────────────────────────── + + submitBackup(): void { + this.error.set(''); + if (!this.backupCode.trim()) return; + this.loading.set(true); + this.api.login2FA(this.pendingTempToken, this.backupCode.trim()).subscribe({ + next: (tokens) => { + this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key); + this.router.navigate(['/dashboard']); + }, + error: () => { + this.error.set('auth.errors.invalid_totp'); + this.loading.set(false); + }, + }); + } + + // ── Recovery email ─────────────────────────────────────────────────────────── + + sendRecovery(): void { + this.loading.set(true); + this.api.request2FARecovery(this.pendingTempToken).subscribe({ + next: (res) => { + this.maskedEmail.set(res?.masked_email ?? ''); + this.recoverySent.set(true); + this.loading.set(false); + }, + error: () => { this.recoverySent.set(true); this.loading.set(false); }, + }); + } + + submitRecoveryCode(): void { + this.error.set(''); + if (!this.recoveryCode.trim()) return; + this.loading.set(true); + this.api.confirm2FARecovery(this.pendingTempToken, this.recoveryCode.trim()).subscribe({ + next: (tokens) => { + this.auth.completeLogin(tokens.access, tokens.refresh, this.keepSignedIn, tokens.session_key); + this.router.navigate(['/dashboard']); + }, + error: () => { + this.error.set('auth.errors.invalid_totp'); + this.loading.set(false); + }, + }); + } + + // ── Countdown ──────────────────────────────────────────────────────────────── + + private startCountdown(): void { + this.updateCountdown(); + this.countdownInterval = setInterval(() => this.updateCountdown(), 1000); + } + + private updateCountdown(): void { + this.totpCountdown.set(30 - (Math.floor(Date.now() / 1000) % 30)); + } + + countdownOffset(): number { + return Math.round(100 * (1 - this.totpCountdown() / 30)); + } + + // ── Navigation helpers ─────────────────────────────────────────────────────── + + goToBackup(): void { + this.error.set(''); + this.backupCode = ''; + this.step.set('backup'); + } + + goToRecovery(): void { + this.error.set(''); + this.recoverySent.set(false); + this.recoveryCode = ''; + this.step.set('recovery'); + } + + goToCredentials(): void { + clearInterval(this.countdownInterval); + this.error.set(''); + this.digits = ['', '', '', '', '', '']; + this.backupCode = ''; + this.recoverySent.set(false); + this.step.set('credentials'); + } +} diff --git a/frontend/src/app/auth/register/register.html b/frontend/src/app/auth/register/register.html new file mode 100644 index 0000000..d97851b --- /dev/null +++ b/frontend/src/app/auth/register/register.html @@ -0,0 +1,142 @@ +
+
+ + +
+ Armarium +

{{ 'auth.tagline_register' | translate }}

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

+ {{ 'auth.create_account' | translate }} +

+

+ {{ 'auth.has_account' | translate }} + + {{ 'auth.sign_in' | translate }} + +

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

{{ 'auth.password_hint' | translate }}

+
+ + +
+ +
+ + +
+
+ +
+ + + @if (error()) { +
+ + + + + {{ error() | translate }} +
+ } + + + + + + +
+
+
diff --git a/frontend/src/app/auth/register/register.ts b/frontend/src/app/auth/register/register.ts new file mode 100644 index 0000000..8c8b04c --- /dev/null +++ b/frontend/src/app/auth/register/register.ts @@ -0,0 +1,69 @@ +import { Component, OnInit, ViewChild, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from '../../services/auth'; +import { LanguageService } from '../../services/language'; +import { ThemeService } from '../../services/theme'; +import { LangSwitcher } from '../lang-switcher/lang-switcher'; +import { TurnstileComponent } from '../turnstile/turnstile'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher, TurnstileComponent], + templateUrl: './register.html', +}) +export class Register implements OnInit { + @ViewChild(TurnstileComponent) private turnstileComp?: TurnstileComponent; + turnstileToken = ''; + email = ''; + password = ''; + confirmPassword = ''; + showPassword = signal(false); + showConfirmPassword = signal(false); + error = signal(''); + loading = signal(false); + + constructor( + private auth: AuthService, + private router: Router, + private langService: LanguageService, + public themeService: ThemeService, + ) {} + + ngOnInit(): void { + const detected = this.langService.detectBrowserLanguage(); + this.langService.setLanguage(detected); + } + + submit(): void { + this.error.set(''); + if (!this.email || !this.password) { + this.error.set('auth.errors.fields_required'); + return; + } + if (this.password !== this.confirmPassword) { + this.error.set('auth.errors.passwords_mismatch'); + return; + } + if (this.password.length < 8) { + this.error.set('auth.errors.password_too_short'); + return; + } + this.loading.set(true); + this.auth.register(this.email, this.password, this.turnstileToken).subscribe({ + next: () => this.router.navigate(['/login']), + error: (err) => { + const data = err.error; + const msg = err.status === 400 && data?.detail === 'Captcha verification failed.' + ? 'auth.errors.captcha_failed' + : (data?.email?.[0] || data?.password?.[0] || 'auth.errors.registration_failed'); + this.error.set(msg); + this.turnstileToken = ''; + this.turnstileComp?.reset(); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/auth/reset-password/reset-password.html b/frontend/src/app/auth/reset-password/reset-password.html new file mode 100644 index 0000000..415af11 --- /dev/null +++ b/frontend/src/app/auth/reset-password/reset-password.html @@ -0,0 +1,139 @@ +
+
+ + +
+ Armarium +

{{ 'auth.reset_password_tagline' | translate }}

+
+ + +
+ + +
+ + +
+ + @if (!success()) { + +

+ {{ 'auth.reset_password' | translate }} +

+

+ {{ 'auth.reset_password_hint' | translate }} +

+ +
+ +
+ +
+ + +
+

{{ 'auth.password_hint' | translate }}

+
+ + +
+ +
+ + +
+
+
+ + + @if (error()) { +
+ + + + {{ error() | translate }} +
+ } + + + + + } @else { + +
+
+ + + +
+

{{ 'auth.reset_success' | translate }}

+

{{ 'auth.recovery_redirecting' | translate }}

+
+ } + + +

+ + {{ 'auth.back_to_login' | translate }} + +

+ +
+
+
diff --git a/frontend/src/app/auth/reset-password/reset-password.ts b/frontend/src/app/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..4b4042c --- /dev/null +++ b/frontend/src/app/auth/reset-password/reset-password.ts @@ -0,0 +1,70 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule, ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; +import { LanguageService } from '../../services/language'; +import { ThemeService } from '../../services/theme'; +import { LangSwitcher } from '../lang-switcher/lang-switcher'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [FormsModule, RouterModule, TranslateModule, LangSwitcher], + templateUrl: './reset-password.html', +}) +export class ResetPassword implements OnInit { + password = ''; + confirmPassword = ''; + showPassword = signal(false); + showConfirmPassword = signal(false); + loading = signal(false); + success = signal(false); + error = signal(''); + private token = ''; + + constructor( + private api: ApiService, + private route: ActivatedRoute, + private router: Router, + private langService: LanguageService, + public themeService: ThemeService, + ) { + this.langService.init(); + } + + ngOnInit(): void { + this.token = this.route.snapshot.queryParamMap.get('token') ?? ''; + if (!this.token) { + this.error.set('auth.errors.token_missing'); + } + } + + submit(): void { + this.error.set(''); + if (!this.password || !this.confirmPassword) { + this.error.set('auth.errors.fields_required'); + return; + } + if (this.password !== this.confirmPassword) { + this.error.set('auth.errors.passwords_mismatch'); + return; + } + if (this.password.length < 8) { + this.error.set('auth.errors.password_too_short'); + return; + } + this.loading.set(true); + this.api.confirmPasswordReset(this.token, this.password).subscribe({ + next: () => { + this.success.set(true); + this.loading.set(false); + setTimeout(() => this.router.navigate(['/login']), 3000); + }, + error: () => { + this.error.set('auth.errors.reset_failed'); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/auth/turnstile/turnstile.ts b/frontend/src/app/auth/turnstile/turnstile.ts new file mode 100644 index 0000000..a5ccde3 --- /dev/null +++ b/frontend/src/app/auth/turnstile/turnstile.ts @@ -0,0 +1,53 @@ +import { Component, Output, EventEmitter, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-turnstile', + standalone: true, + template: '
', +}) +export class TurnstileComponent implements AfterViewInit, OnDestroy { + @ViewChild('container', { static: true }) private container!: ElementRef; + @Output() resolved = new EventEmitter(); + + private widgetId = ''; + private pollId?: ReturnType; + + ngAfterViewInit(): void { + if (window.location.hostname === 'localhost') { + setTimeout(() => this.resolved.emit('dev-bypass'), 0); + return; + } + this.poll(); + } + + private poll(): void { + if ((window as any).turnstile) { + this.render(); + } else { + this.pollId = setTimeout(() => this.poll(), 100); + } + } + + private render(): void { + this.widgetId = (window as any).turnstile.render(this.container.nativeElement, { + sitekey: '0x4AAAAAADRzQr8OmvZ5s7NA', + theme: 'auto', + callback: (token: string) => this.resolved.emit(token), + 'expired-callback': () => this.resolved.emit(''), + 'error-callback': () => this.resolved.emit(''), + }); + } + + reset(): void { + if (this.widgetId && (window as any).turnstile) { + (window as any).turnstile.reset(this.widgetId); + } + } + + ngOnDestroy(): void { + clearTimeout(this.pollId); + if (this.widgetId && (window as any).turnstile) { + (window as any).turnstile.remove(this.widgetId); + } + } +} diff --git a/frontend/src/app/auth/verify-email/verify-email.html b/frontend/src/app/auth/verify-email/verify-email.html new file mode 100644 index 0000000..f5ced53 --- /dev/null +++ b/frontend/src/app/auth/verify-email/verify-email.html @@ -0,0 +1,69 @@ +
+
+ + +
+ Armarium +
+ + +
+ + +
+ +
+ +
+ @if (state() === 'loading') { +
+ + + + +
+

{{ 'auth.verifying' | translate }}

+ } + + @if (state() === 'success') { +
+ + + +
+

{{ 'auth.email_verified' | translate }}

+

{{ 'auth.recovery_redirecting' | translate }}

+ } + + @if (state() === 'error') { +
+ + + +
+

{{ 'auth.verify_email_error' | translate }}

+

{{ 'auth.errors.verify_failed' | translate }}

+ } +
+ + +

+ + {{ 'auth.back_to_login' | translate }} + +

+ +
+
+
diff --git a/frontend/src/app/auth/verify-email/verify-email.ts b/frontend/src/app/auth/verify-email/verify-email.ts new file mode 100644 index 0000000..6cce50e --- /dev/null +++ b/frontend/src/app/auth/verify-email/verify-email.ts @@ -0,0 +1,40 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { RouterModule, ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; +import { LanguageService } from '../../services/language'; +import { ThemeService } from '../../services/theme'; +@Component({ + selector: 'app-verify-email', + standalone: true, + imports: [RouterModule, TranslateModule], + templateUrl: './verify-email.html', +}) +export class VerifyEmail implements OnInit { + state = signal<'loading' | 'success' | 'error'>('loading'); + + constructor( + private api: ApiService, + private route: ActivatedRoute, + private router: Router, + private langService: LanguageService, + public themeService: ThemeService, + ) { + this.langService.init(); + } + + ngOnInit(): void { + const token = this.route.snapshot.queryParamMap.get('token') ?? ''; + if (!token) { + this.state.set('error'); + return; + } + this.api.verifyEmail(token).subscribe({ + next: () => { + this.state.set('success'); + setTimeout(() => this.router.navigate(['/login']), 3000); + }, + error: () => this.state.set('error'), + }); + } +} diff --git a/frontend/src/app/budgets/budgets.css b/frontend/src/app/budgets/budgets.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/budgets/budgets.html b/frontend/src/app/budgets/budgets.html new file mode 100644 index 0000000..069df6a --- /dev/null +++ b/frontend/src/app/budgets/budgets.html @@ -0,0 +1,327 @@ + +
+
+

{{ 'budgets.title' | translate }}

+

+ {{ 'budgets.subtitle' | translate }}{{ grandTotal() | number:'1.2-2' }} CHF +

+
+
+ + +
+ @for (group of categoryGroups; track group.key) { +
+ + +
+
+

{{ group.label | translate }}

+ @if (budgetsForCategory(group.key).length > 0) { + + {{ budgetsForCategory(group.key).length }} + + } +
+
+ + {{ totalForCategory(group.key) | number:'1.2-2' }} CHF + + +
+
+ + + @if (budgetsForCategory(group.key).length > 0) { +
+ @for (budget of budgetsForCategory(group.key); track budget.id) { +
+
+ + +
+ {{ budget.name }} + {{ accountName(budget.account) }} +
+
+
+ + {{ budget.amount | number:'1.2-2' }} CHF + + + +
+
+ } +
+ } @else { +
+ {{ 'budgets.no_entries' | translate }} +
+ } + +
+ } +
+ + + +@if (showNoAccountsModal()) { +
+
+
+
+ +
+
+
+ + + + +
+

{{ 'common.no_accounts_title' | translate }}

+
+ +
+ +

{{ 'common.no_accounts_text' | translate }}

+ +
+ + + {{ 'common.go_to_accounts' | translate }} + +
+
+
+
+} + + + +@if (showCreateModal()) { +
+
+
+
+ + +
+

+ {{ 'budgets.new_entry' | translate: { category: (labelForCategory(newCategory) | translate) } }} +

+ +
+ + +
+ + + @if (currentSuggestions.length > 0) { +
+

{{ 'budgets.label_suggestions' | translate }}

+
+ @for (s of currentSuggestions; track s) { + + } +
+
+ } + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+} + + + +@if (showEditModal()) { +
+
+
+
+ + +
+

{{ 'budgets.edit_entry' | translate }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+} + + + +@if (showDeleteModal()) { +
+
+
+
+ +
+ + + + +
+ +

{{ 'common.delete_confirm_title' | translate }}

+

{{ 'common.delete_confirm_text' | translate }}

+ +
+ + +
+ +
+
+
+} diff --git a/frontend/src/app/budgets/budgets.spec.ts b/frontend/src/app/budgets/budgets.spec.ts new file mode 100644 index 0000000..88998e5 --- /dev/null +++ b/frontend/src/app/budgets/budgets.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Budgets } from './budgets'; + +describe('Budgets', () => { + let component: Budgets; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Budgets], + }).compileComponents(); + + fixture = TestBed.createComponent(Budgets); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/budgets/budgets.ts b/frontend/src/app/budgets/budgets.ts new file mode 100644 index 0000000..6e16b94 --- /dev/null +++ b/frontend/src/app/budgets/budgets.ts @@ -0,0 +1,231 @@ +import { Component, OnInit, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../services/api'; + +export const CATEGORY_GROUPS = [ + { + key: 'fixed_expenses', + label: 'budgets.categories.fixed_expenses', + suggestions: ['Miete / Wohnung', 'Nahrungsmittel', 'Strom'], + }, + { + key: 'mobile_internet', + label: 'budgets.categories.mobile_internet', + suggestions: ['Mobile', 'Internet'], + }, + { + key: 'subscriptions', + label: 'budgets.categories.subscriptions', + suggestions: ['Netflix', 'Disney+', 'Prime', 'Youtube'], + }, + { + key: 'leisure', + label: 'budgets.categories.leisure', + suggestions: [], + }, + { + key: 'tax_reserves', + label: 'budgets.categories.tax_reserves', + suggestions: [], + }, + { + key: 'insurance', + label: 'budgets.categories.insurance', + suggestions: ['3. Säule', 'Privathaftpflicht'], + }, + { + key: 'loans', + label: 'budgets.categories.loans', + suggestions: [], + }, +]; + +@Component({ + selector: 'app-budgets', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule, TranslateModule], + templateUrl: './budgets.html', + styleUrl: './budgets.css', +}) +export class Budgets implements OnInit { + budgets = signal([]); + accounts = signal([]); + + categoryGroups = CATEGORY_GROUPS; + + // No Accounts Modal + showNoAccountsModal = signal(false); + + // Create Modal + showCreateModal = signal(false); + newName = ''; + newAmount = 0; + newCategory = 'fixed_expenses'; + newAccountId: number | null = null; + newActive = true; + currentSuggestions: string[] = []; + + // Edit Modal + showEditModal = signal(false); + editId = 0; + + // Delete Modal + showDeleteModal = signal(false); + deleteTargetId = 0; + editName = ''; + editAmount = 0; + editCategory = 'fixed_expenses'; + editAccountId: number | null = null; + editActive = true; + + constructor(private api: ApiService) {} + + ngOnInit(): void { + this.loadBudgets(); + this.loadAccounts(); + } + + loadBudgets() { + this.api.getBudgets().subscribe({ + next: (data) => this.budgets.set(data), + error: (err) => console.error('Fehler:', err), + }); + } + + loadAccounts() { + this.api.getAccounts().subscribe({ + next: (data) => { + this.accounts.set(data.filter((a) => a.active)); + }, + error: (err) => console.error('Fehler:', err), + }); + } + + budgetsForCategory(categoryKey: string): any[] { + return this.budgets().filter((b) => b.main_category === categoryKey); + } + + totalForCategory(categoryKey: string): number { + return this.budgetsForCategory(categoryKey).reduce( + (sum, b) => sum + parseFloat(b.amount), + 0 + ); + } + + grandTotal(): number { + return this.budgets().reduce((sum, b) => sum + parseFloat(b.amount), 0); + } + + accountName(id: number): string { + return this.accounts().find((a) => a.id === id)?.name ?? '–'; + } + + labelForCategory(key: string): string { + return CATEGORY_GROUPS.find((g) => g.key === key)?.label ?? key; + } + + closeNoAccountsModal() { + this.showNoAccountsModal.set(false); + } + + // Create + openCreateModal(categoryKey: string) { + if (this.accounts().length === 0) { + this.showNoAccountsModal.set(true); + return; + } + this.newCategory = categoryKey; + this.newName = ''; + this.newAmount = 0; + this.newAccountId = this.accounts()[0].id; + this.newActive = true; + this.currentSuggestions = + CATEGORY_GROUPS.find((g) => g.key === categoryKey)?.suggestions ?? []; + this.showCreateModal.set(true); + } + + closeCreateModal() { + this.showCreateModal.set(false); + } + + applySuggestion(name: string) { + this.newName = name; + } + + createBudget() { + if (!this.newName || !this.newAccountId) return; + this.api + .createBudget({ + name: this.newName, + amount: this.newAmount, + main_category: this.newCategory, + account: this.newAccountId, + active: this.newActive, + }) + .subscribe({ + next: () => { + this.loadBudgets(); + this.closeCreateModal(); + }, + error: (err) => console.error('Fehler beim Erstellen:', err), + }); + } + + // Edit + openEditModal(budget: any) { + this.editId = budget.id; + this.editName = budget.name; + this.editAmount = budget.amount; + this.editCategory = budget.main_category; + this.editAccountId = budget.account; + this.editActive = budget.active; + this.showEditModal.set(true); + } + + closeEditModal() { + this.showEditModal.set(false); + } + + updateBudget() { + if (!this.editName || !this.editAccountId) return; + this.api + .updateBudget(this.editId, { + name: this.editName, + amount: this.editAmount, + main_category: this.editCategory, + account: this.editAccountId, + active: this.editActive, + }) + .subscribe({ + next: () => { + this.loadBudgets(); + this.closeEditModal(); + }, + error: (err) => console.error('Fehler beim Bearbeiten:', err), + }); + } + + // Delete + openDeleteModal(id: number) { + this.deleteTargetId = id; + this.showDeleteModal.set(true); + } + + closeDeleteModal() { + this.showDeleteModal.set(false); + this.deleteTargetId = 0; + } + + confirmDelete() { + this.api.deleteBudget(this.deleteTargetId).subscribe({ + next: () => { + this.loadBudgets(); + this.closeDeleteModal(); + }, + error: (err) => console.error('Error deleting budget:', err), + }); + } +} diff --git a/frontend/src/app/calendar/calendar.html b/frontend/src/app/calendar/calendar.html new file mode 100644 index 0000000..a7830be --- /dev/null +++ b/frontend/src/app/calendar/calendar.html @@ -0,0 +1,328 @@ +
+ + +
+ + +
+ +

{{ currentYear() }}

+ +
+ + + + + +
+ + + + +
+ + @if (viewMode() === 'month') { + + } + + + @if (icalOpen()) { +
+ } +
+ + + @if (icalOpen()) { +
+
+

{{ 'calendar.ical_title' | translate }}

+ +
+

{{ 'calendar.ical_desc' | translate }}

+
+ + +
+
+ +
+
+ } +
+
+ + + @if (viewMode() === 'year') { +
+ @for (m of getMonthsData(); track m.month) { +
+

+ {{ m.name | translate }} +

+ +
+ @for (dl of dayLabels; track dl) { +
{{ dl | translate }}
+ } +
+ +
+ @for (cell of m.cells; track $index) { + @if (cell.date) { + + } @else { +
+ } + } +
+
+ } +
+ } + + + @if (viewMode() === 'month') { +
+ +
+ + + {{ monthNames[currentMonth()-1] | translate }} {{ currentYear() }} + + +
+ +
+ +
+ @for (dl of dayLabels; track dl) { +
{{ dl | translate }}
+ } +
+ +
+ @for (cell of buildMonthGrid(currentYear(), currentMonth()); track $index) { + @if (cell.date) { + + } @else { +
+ } + } +
+
+
+ } + + + @if (selectedDate()) { +
+
+ } + +
+ + +
+

{{ selectedDate() }}

+ +
+ + +
+ @if (selectedEvents().length === 0 && !showAddDeadline()) { +

{{ 'calendar.no_events' | translate }}

+ } + + @for (event of selectedEvents(); track $index) { +
+
+ +
+

{{ event.title }}

+

{{ eventTypeLabel(event.type) | translate }}

+ @if (event.amount) { +

{{ event.amount | number:'1.2-2' }} CHF

+ } +
+
+ @if (event.type === 'deadline') { + + } +
+ } + + + @if (showAddDeadline()) { +
+

{{ 'calendar.add_deadline' | translate }}

+
+ + + +
+ + +
+
+
+ } +
+ + +
+ +
+
+ + +
diff --git a/frontend/src/app/calendar/calendar.ts b/frontend/src/app/calendar/calendar.ts new file mode 100644 index 0000000..8e6cbe1 --- /dev/null +++ b/frontend/src/app/calendar/calendar.ts @@ -0,0 +1,331 @@ +import { Component, OnInit, OnDestroy, effect, signal } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { ApiService } from '../services/api'; +import { HolidaysService } from '../services/holidays'; +import { CalendarEvent, CANTONS } from '../data/swiss-holidays'; + +const MONTH_NAMES = [ + 'calendar.months.1','calendar.months.2','calendar.months.3','calendar.months.4', + 'calendar.months.5','calendar.months.6','calendar.months.7','calendar.months.8', + 'calendar.months.9','calendar.months.10','calendar.months.11','calendar.months.12', +]; +const DAY_LABELS = [ + 'calendar.weekdays.0','calendar.weekdays.1','calendar.weekdays.2','calendar.weekdays.3', + 'calendar.weekdays.4','calendar.weekdays.5','calendar.weekdays.6', +]; + +export interface DayCell { + date: string | null; // 'YYYY-MM-DD' or null for padding + day: number | null; + isToday: boolean; + isWeekend: boolean; + events: CalendarEvent[]; +} + +@Component({ + selector: 'app-calendar', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './calendar.html', +}) +export class Calendar implements OnInit, OnDestroy { + readonly cantons = CANTONS; + readonly monthNames = MONTH_NAMES; + readonly dayLabels = DAY_LABELS; + + currentYear = signal(new Date().getFullYear()); + viewMode = signal<'year' | 'month'>('year'); + currentMonth = signal(1); + selectedDate = signal(null); + canton = signal('ZH'); + + filters = signal({ holidays: true, school: true, expenses: true, deadlines: true }); + + holidays = signal([]); + schoolHolidays = signal([]); + currentLang = signal(typeof window !== 'undefined' ? (localStorage.getItem('lang') ?? 'de') : 'de'); + private langSub?: Subscription; + + expenses = signal([]); + deadlines = signal([]); + + // Add deadline form + showAddDeadline = signal(false); + newDeadlineTitle = ''; + newDeadlineDate = ''; + newDeadlineType = 'other'; + newDeadlineNotes = ''; + + // iCal + icalUrl = signal(null); + icalOpen = signal(false); + icalCopied = signal(false); + + toggleIcal() { + if (!this.icalUrl()) { + this.api.getICalUrl().subscribe({ next: (r) => { + this.icalUrl.set(r.url); + this.icalOpen.set(true); + }}); + } else { + this.icalOpen.update(v => !v); + } + } + + closeIcal() { this.icalOpen.set(false); } + + copyICalUrl() { + const url = this.icalUrl(); + if (!url) return; + navigator.clipboard.writeText(url).then(() => { + this.icalCopied.set(true); + setTimeout(() => this.icalCopied.set(false), 2000); + }); + } + + + // Edit deadline + editDeadlineId: number | null = null; + showEditDeadline = signal(false); + editDeadlineTitle = ''; + editDeadlineDate = ''; + editDeadlineType = 'other'; + + deadlineTypes = [ + { key: 'tax', label: 'calendar.deadline_types.tax' }, + { key: 'insurance', label: 'calendar.deadline_types.insurance' }, + { key: 'invoice', label: 'calendar.deadline_types.invoice' }, + { key: 'personal', label: 'calendar.deadline_types.personal' }, + { key: 'other', label: 'calendar.deadline_types.other' }, + ]; + + readonly today = new Date().toISOString().split('T')[0]; + + constructor( + private api: ApiService, + private route: ActivatedRoute, + private holidaysService: HolidaysService, + public translate: TranslateService, + ) { + effect(() => { + this.loadHolidays(this.currentYear(), this.canton(), this.currentLang()); + }); + } + + ngOnDestroy(): void { + this.langSub?.unsubscribe(); + } + + private loadHolidays(year: number, canton: string, lang: string): void { + this.holidays.set([]); + this.schoolHolidays.set([]); + this.holidaysService.getPublicHolidays(year, canton, lang).subscribe(events => this.holidays.set(events)); + this.holidaysService.getSchoolHolidays(year, canton, lang).subscribe(events => this.schoolHolidays.set(events)); + } + + ngOnInit(): void { + this.api.getProfile().subscribe({ next: (p) => { if (p.canton) this.canton.set(p.canton); } }); + this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) }); + this.api.getDeadlines().subscribe({ next: (d) => this.deadlines.set(d) }); + this.langSub = this.translate.onLangChange.subscribe(e => this.currentLang.set(e.lang)); + + this.route.queryParams.subscribe(params => { + if (params['year']) this.currentYear.set(+params['year']); + if (params['month']) { + this.currentMonth.set(+params['month']); + this.viewMode.set('month'); + } + }); + } + + // ── Event Map ──────────────────────────────────────────────────────────────── + + private buildEventMap(year: number): Map { + const map = new Map(); + const add = (e: CalendarEvent) => { + if (!map.has(e.date)) map.set(e.date, []); + map.get(e.date)!.push(e); + }; + + const f = this.filters(); + if (f.holidays) { + this.holidays().forEach(add); + } + if (f.school) { + this.schoolHolidays().forEach(add); + } + if (f.expenses) { + this.expenses() + .filter((e) => e.due_date && e.due_date.startsWith(String(year))) + .forEach((e) => add({ date: e.due_date, title: e.name, type: 'expense', amount: parseFloat(e.amount), color: '#8b5cf6', id: e.id })); + } + if (f.deadlines) { + this.deadlines() + .filter((d) => d.date.startsWith(String(year))) + .forEach((d) => add({ date: d.date, title: d.title, type: 'deadline', color: '#3b82f6', id: d.id })); + } + return map; + } + + // ── Month Grid ─────────────────────────────────────────────────────────────── + + buildMonthGrid(year: number, month: number): DayCell[] { + const map = this.buildEventMap(year); + const cells: DayCell[] = []; + const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun + const offset = (firstDay + 6) % 7; // Monday-based offset + const daysInMonth = new Date(year, month, 0).getDate(); + + for (let i = 0; i < offset; i++) cells.push({ date: null, day: null, isToday: false, isWeekend: false, events: [] }); + + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const dow = (offset + d - 1) % 7; // 0=Mon, 5=Sat, 6=Sun + cells.push({ + date: dateStr, + day: d, + isToday: dateStr === this.today, + isWeekend: dow >= 5, + events: map.get(dateStr) ?? [], + }); + } + return cells; + } + + getMonthsData(): { month: number; name: string; cells: DayCell[] }[] { + return Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + name: MONTH_NAMES[i], + cells: this.buildMonthGrid(this.currentYear(), i + 1), + })); + } + + // ── Navigation ─────────────────────────────────────────────────────────────── + + prevYear() { this.currentYear.update((y) => y - 1); this.selectedDate.set(null); } + nextYear() { this.currentYear.update((y) => y + 1); this.selectedDate.set(null); } + + openMonth(month: number) { + this.currentMonth.set(month); + this.viewMode.set('month'); + this.selectedDate.set(null); + } + + backToYear() { + this.viewMode.set('year'); + this.selectedDate.set(null); + } + + prevMonth() { + if (this.currentMonth() === 1) { this.currentMonth.set(12); this.currentYear.update((y) => y - 1); } + else this.currentMonth.update((m) => m - 1); + this.selectedDate.set(null); + } + + nextMonth() { + if (this.currentMonth() === 12) { this.currentMonth.set(1); this.currentYear.update((y) => y + 1); } + else this.currentMonth.update((m) => m + 1); + this.selectedDate.set(null); + } + + // ── Day selection ───────────────────────────────────────────────────────────── + + selectDay(cell: DayCell) { + if (!cell.date) return; + this.selectedDate.set(cell.date === this.selectedDate() ? null : cell.date); + this.showAddDeadline.set(false); + } + + selectedEvents(): CalendarEvent[] { + const d = this.selectedDate(); + if (!d) return []; + const map = this.buildEventMap(this.currentYear()); + return map.get(d) ?? []; + } + + // ── Filter & Canton ─────────────────────────────────────────────────────────── + + toggleFilter(key: keyof ReturnType) { + this.filters.update((f) => ({ ...f, [key]: !f[key] })); + } + + onCantonChange(code: string) { + this.canton.set(code); + this.api.updateProfile({ canton: code }).subscribe(); + } + + // ── Deadline CRUD ───────────────────────────────────────────────────────────── + + openAddDeadline() { + this.newDeadlineTitle = ''; + this.newDeadlineDate = this.selectedDate() ?? ''; + this.newDeadlineType = ''; + this.newDeadlineNotes = ''; + this.showAddDeadline.set(true); + } + + saveDeadline() { + if (!this.newDeadlineTitle || !this.newDeadlineDate) return; + this.api.createDeadline({ + title: this.newDeadlineTitle, + date: this.newDeadlineDate, + type: this.newDeadlineType || 'other', + notes: this.newDeadlineNotes, + }).subscribe({ next: (d) => { this.deadlines.update((dl) => [...dl, d]); this.showAddDeadline.set(false); } }); + } + + openEditDeadline(event: CalendarEvent) { + this.editDeadlineId = event.id!; + this.editDeadlineTitle = event.title; + this.editDeadlineDate = event.date; + this.editDeadlineType = 'other'; + this.showEditDeadline.set(true); + } + + updateDeadline() { + if (!this.editDeadlineId) return; + this.api.updateDeadline(this.editDeadlineId, { + title: this.editDeadlineTitle, + date: this.editDeadlineDate, + type: this.editDeadlineType, + }).subscribe({ next: (updated) => { + this.deadlines.update((dl) => dl.map((d) => d.id === this.editDeadlineId ? updated : d)); + this.showEditDeadline.set(false); + }}); + } + + deleteDeadline(id: number) { + this.api.deleteDeadline(id).subscribe({ next: () => { + this.deadlines.update((dl) => dl.filter((d) => d.id !== id)); + }}); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + eventTypeLabel(type: string): string { + const keys: Record = { + national: 'calendar.filter_holidays', + canton: 'calendar.filter_holidays', + school: 'calendar.filter_school', + deadline: 'calendar.filter_deadlines', + expense: 'calendar.filter_invoices', + }; + return keys[type] ?? type; + } + + dotClasses(type: string): string { + const colors: Record = { + national: 'bg-orange-400', canton: 'bg-red-400', + school: 'bg-emerald-400', deadline: 'bg-blue-400', expense: 'bg-violet-400', + }; + return colors[type] ?? 'bg-gray-400'; + } + + uniqueEventTypes(events: CalendarEvent[]): string[] { + return [...new Set(events.map((e) => e.type))]; + } +} diff --git a/frontend/src/app/dashboard/dashboard.css b/frontend/src/app/dashboard/dashboard.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/dashboard/dashboard.html b/frontend/src/app/dashboard/dashboard.html new file mode 100644 index 0000000..b330491 --- /dev/null +++ b/frontend/src/app/dashboard/dashboard.html @@ -0,0 +1,246 @@ + +
+

{{ greeting() }}

+

{{ dateTimeDisplay() }}

+
+ + +
+ +
+

{{ 'dashboard.total_income' | translate }}

+

{{ totalIncome() | number:'1.2-2' }}

+

{{ 'dashboard.per_month' | translate }}

+
+ +
+

{{ 'dashboard.fixed_costs' | translate }}

+

{{ totalFixedCosts() | number:'1.2-2' }}

+

{{ 'dashboard.per_month' | translate }}

+
+ +
+

{{ 'dashboard.expenses' | translate }}

+

{{ totalExpenses() | number:'1.2-2' }}

+

{{ 'dashboard.chf_total' | translate }}

+
+ +
+

{{ 'dashboard.balance' | translate }}

+

+ {{ balance() | number:'1.2-2' }} +

+

{{ 'dashboard.chf_remaining' | translate }}

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

{{ 'dashboard.income_vs_expenses' | translate: { year: selectedYear() } }}

+
+
+
+ + +
+ + +
+
+ + +
+ + @if (yearDropdownOpen()) { +
+
    + @for (year of availableYears(); track year) { +
  • + +
  • + } +
+
+ } +
+ + +
+
+
+ + +
+ + +
+

{{ 'dashboard.fixed_costs_breakdown' | translate }}

+ +
+ + +
+ + +
+ @for (item of donutItems(); track item.name) { +
+ {{ item.name }} + CHF {{ item.amount | number:'1.2-2' }} + {{ item.pct }}% +
+ } +
+ +
+ +
+ + +
+ + +
+ + +
+

{{ 'dashboard.savings_rate' | translate }}

+ +
+ + + @if (!savingsSettingsOpen()) { +
+ {{ savingsRate() }}% + {{ 'dashboard.of_income' | translate }} +
+
+
+
+
+
+
+
+ 0% + {{ 'dashboard.goal' | translate }}: {{ savingsGoal() }}% + 100% +
+ + + } @else { +
+
+ +
+ + % +
+

{{ 'dashboard.goal_hint' | translate }}

+
+ + +
+
+
+
+
+ 0% + {{ goalInputValue() }}% + 100% +
+ +
+ + +
+
+ } + +
+ + +
+

{{ 'dashboard.recent_expenses' | translate }}

+ @if (recentExpenses().length === 0) { +

{{ 'dashboard.no_expenses' | translate }}

+ } @else { +
+ @for (expense of recentExpenses(); track expense.id) { +
+
+

{{ expense.name }}

+

{{ expense.date | date:'dd.MM.yyyy' }} · {{ ('expenses.categories.' + expense.category) | translate }}

+
+ -{{ expense.amount | number:'1.2-2' }} CHF +
+ } +
+ } +
+ +
diff --git a/frontend/src/app/dashboard/dashboard.spec.ts b/frontend/src/app/dashboard/dashboard.spec.ts new file mode 100644 index 0000000..2ce950a --- /dev/null +++ b/frontend/src/app/dashboard/dashboard.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Dashboard } from './dashboard'; + +describe('Dashboard', () => { + let component: Dashboard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Dashboard], + }).compileComponents(); + + fixture = TestBed.createComponent(Dashboard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/dashboard/dashboard.ts b/frontend/src/app/dashboard/dashboard.ts new file mode 100644 index 0000000..85498c1 --- /dev/null +++ b/frontend/src/app/dashboard/dashboard.ts @@ -0,0 +1,358 @@ +import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ApiService } from '../services/api'; +import ApexCharts from 'apexcharts'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [CommonModule, TranslateModule], + templateUrl: './dashboard.html', + styleUrl: './dashboard.css', +}) +export class Dashboard implements OnInit, AfterViewInit, OnDestroy { + accounts = signal([]); + budgets = signal([]); + expenses = signal([]); + transactions = signal([]); + donutExpanded = signal(false); + selectedYear = signal(new Date().getFullYear()); + yearDropdownOpen = signal(false); + savingsGoal = signal(20); + savingsSettingsOpen = signal(false); + goalInputValue = signal(20); + + greeting = signal(''); + dateTimeDisplay = signal(''); + + private firstName = ''; + private readonly donutColors = ['#7c3aed', '#a78bfa', '#5b21b6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444']; + private barChart?: ApexCharts; + private donutChart?: ApexCharts; + private dataLoaded = 0; + private readonly totalRequests = 4; + private timeInterval?: ReturnType; + private langSub?: Subscription; + + constructor(private api: ApiService, private translate: TranslateService) {} + + ngOnInit(): void { + this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } }); + this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } }); + this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } }); + this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } }); + + this.api.getProfile().subscribe({ + next: (p) => { + this.firstName = p.first_name || ''; + const goal = p.savings_rate_goal ?? 20; + this.savingsGoal.set(goal); + this.goalInputValue.set(goal); + this.updateHeader(); + }, + }); + + this.updateHeader(); + this.timeInterval = setInterval(() => this.updateHeader(), 30000); + this.langSub = this.translate.onLangChange.subscribe(() => this.updateHeader()); + } + + ngAfterViewInit(): void {} + + ngOnDestroy(): void { + this.barChart?.destroy(); + this.donutChart?.destroy(); + clearInterval(this.timeInterval); + this.langSub?.unsubscribe(); + } + + private onDataLoaded(): void { + this.dataLoaded++; + if (this.dataLoaded === this.totalRequests) { + setTimeout(() => this.renderCharts(), 50); + } + } + + private getLocale(): string { + const lang = this.translate.currentLang || 'de'; + return ({ de: 'de-CH', fr: 'fr-CH', it: 'it-CH', en: 'en-GB' } as Record)[lang] ?? 'de-CH'; + } + + private updateHeader(): void { + const now = new Date(); + const hour = now.getHours(); + const locale = this.getLocale(); + + let key: string; + if (hour >= 5 && hour < 12) key = 'dashboard.greeting_morning'; + else if (hour >= 12 && hour < 18) key = 'dashboard.greeting_afternoon'; + else if (hour >= 18 && hour < 22) key = 'dashboard.greeting_evening'; + else key = 'dashboard.greeting_night'; + + const greet = this.translate.instant(key); + this.greeting.set(this.firstName ? `${greet} ${this.firstName}` : greet); + + const weekday = now.toLocaleDateString(locale, { weekday: 'long' }); + const date = now.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }); + const time = now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`); + } + + // KPIs + totalIncome(): number { + return this.accounts() + .filter((a) => a.account_type === 'revenue') + .reduce((sum, a) => sum + parseFloat(a.balance), 0); + } + + totalFixedCosts(): number { + return this.budgets() + .filter((b) => b.active) + .reduce((sum, b) => sum + parseFloat(b.amount), 0); + } + + totalExpenses(): number { + return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0); + } + + balance(): number { + return this.totalIncome() - this.totalFixedCosts() - this.totalExpenses(); + } + + savingsRate(): number { + const income = this.totalIncome(); + if (income === 0) return 0; + return Math.max(0, Math.round((this.balance() / income) * 100)); + } + + savingsRateColor(): string { + const rate = this.savingsRate(); + const goal = this.savingsGoal(); + if (rate >= goal) return 'bg-emerald-500'; + if (rate >= goal / 2) return 'bg-yellow-400'; + return 'bg-red-500'; + } + + toggleSavingsSettings(): void { + this.goalInputValue.set(this.savingsGoal()); + this.savingsSettingsOpen.set(!this.savingsSettingsOpen()); + } + + saveGoal(): void { + const val = Math.min(100, Math.max(1, this.goalInputValue())); + this.api.updateProfile({ savings_rate_goal: val }).subscribe({ + next: () => { + this.savingsGoal.set(val); + this.savingsSettingsOpen.set(false); + }, + }); + } + + toggleDonut(): void { + this.donutExpanded.set(!this.donutExpanded()); + } + + toggleYearDropdown(): void { + this.yearDropdownOpen.set(!this.yearDropdownOpen()); + } + + selectYear(year: number): void { + this.selectedYear.set(year); + this.yearDropdownOpen.set(false); + this.renderBarChart(); + } + + availableYears(): number[] { + const years = new Set([new Date().getFullYear()]); + this.expenses().forEach(e => years.add(new Date(e.date).getFullYear())); + return Array.from(years).sort((a, b) => b - a); + } + + donutItems(): { name: string; amount: number; pct: string; color: string }[] { + const active = this.budgets().filter((b) => b.active); + const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0); + return active.map((b, i) => ({ + name: b.name, + amount: parseFloat(b.amount), + pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0', + color: this.donutColors[i % this.donutColors.length], + })); + } + + recentExpenses(): any[] { + return [...this.expenses()] + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 5); + } + + // Charts + private renderCharts(): void { + this.renderBarChart(); + this.renderDonutChart(); + } + + private renderBarChart(): void { + const el = document.getElementById('bar-chart'); + if (!el) return; + + const year = this.selectedYear(); + const months = Array.from({ length: 12 }, (_, i) => + `${year}-${String(i + 1).padStart(2, '0')}` + ); + + const income = this.totalIncome(); + const fixedCosts = this.totalFixedCosts(); + const incomeData = months.map(() => +income.toFixed(2)); + const fixedData = months.map(() => +fixedCosts.toFixed(2)); + const variableData = months.map((m) => + +this.expenses() + .filter((e) => e.date.startsWith(m)) + .reduce((sum, e) => sum + parseFloat(e.amount), 0) + .toFixed(2) + ); + + const locale = this.getLocale(); + const labels = months.map((m) => { + const [y, month] = m.split('-'); + return new Date(+y, +month - 1).toLocaleString(locale, { month: 'short' }); + }); + const fullLabels = months.map((m) => { + const [y, month] = m.split('-'); + return new Date(+y, +month - 1).toLocaleString(locale, { month: 'long', year: 'numeric' }); + }); + + const brandColor = '#7c3aed'; + + const t = (k: string) => this.translate.instant(k); + const options = { + series: [ + { name: t('dashboard.series_income'), color: '#10b981', data: incomeData }, + { name: t('dashboard.series_fixed_costs'), color: brandColor, data: fixedData }, + { name: t('dashboard.series_expenses'), color: '#f97316', data: variableData }, + ], + chart: { + type: 'bar', + height: '240px', + fontFamily: 'Roboto, sans-serif', + toolbar: { show: false }, + }, + plotOptions: { + bar: { + horizontal: false, + columnWidth: '70%', + borderRadiusApplication: 'end', + borderRadius: 8, + }, + }, + tooltip: { + shared: true, + intersect: false, + custom: ({ series, dataPointIndex, w }: any) => { + const month = fullLabels[dataPointIndex] ?? w.globals.labels[dataPointIndex]; + const rows = (w.globals.seriesNames as string[]).map((name: string, i: number) => { + const val = (series[i][dataPointIndex] as number).toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return `
+ + ${name}: CHF ${val} +
`; + }).join(''); + return `
+
${month}
+ ${rows} +
`; + }, + }, + states: { + hover: { filter: { type: 'darken', value: 1 } }, + }, + stroke: { show: true, width: 0, colors: ['transparent'] }, + grid: { show: false, padding: { left: 2, right: 2, top: -14 } }, + dataLabels: { enabled: false }, + legend: { show: false }, + xaxis: { + floating: false, + categories: labels, + labels: { + show: true, + style: { fontFamily: 'Roboto, sans-serif', cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400' }, + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: { show: false }, + fill: { opacity: 1 }, + responsive: [{ + breakpoint: 640, + options: { + chart: { height: '200px' }, + xaxis: { labels: { rotate: -45, style: { fontSize: '10px' } } }, + plotOptions: { bar: { columnWidth: '85%', borderRadius: 5 } }, + }, + }], + }; + + this.barChart?.destroy(); + this.barChart = new ApexCharts(el, options); + this.barChart.render(); + } + + private renderDonutChart(): void { + const el = document.getElementById('donut-chart'); + if (!el) return; + + const active = this.budgets().filter((b) => b.active); + const labels = active.map((b) => b.name); + const series = active.map((b) => parseFloat(b.amount)); + + if (series.length === 0) return; + + const neutralBg = '#ffffff'; + const colors = this.donutColors.slice(0, series.length); + + const options = { + series, + labels, + chart: { + type: 'pie', + height: 320, + width: '100%', + toolbar: { show: false }, + fontFamily: 'Roboto, sans-serif', + }, + colors, + stroke: { colors: [neutralBg] }, + plotOptions: { + pie: { + labels: { show: true }, + size: '100%', + dataLabels: { offset: -25 }, + }, + }, + dataLabels: { + enabled: true, + style: { fontFamily: 'Roboto, sans-serif', fontSize: '12px', fontWeight: '600' }, + formatter: (val: number) => `${val.toFixed(1)}%`, + }, + legend: { show: false }, + tooltip: { + custom: ({ series, seriesIndex, w }: any) => { + const label = w.globals.labels[seriesIndex]; + const value = (series[seriesIndex] as number).toFixed(2); + const total = (series as number[]).reduce((a, b) => a + b, 0); + const pct = total > 0 ? ((series[seriesIndex] / total) * 100).toFixed(1) : '0'; + return `
+
${label}
+
CHF ${value}
+
${pct}%
+
`; + }, + }, + }; + + this.donutChart?.destroy(); + this.donutChart = new ApexCharts(el, options); + this.donutChart.render(); + } +} diff --git a/frontend/src/app/data/swiss-holidays.ts b/frontend/src/app/data/swiss-holidays.ts new file mode 100644 index 0000000..0492f8a --- /dev/null +++ b/frontend/src/app/data/swiss-holidays.ts @@ -0,0 +1,177 @@ +export interface Canton { + code: string; + name: string; +} + +export const CANTONS: Canton[] = [ + { code: 'AG', name: 'Aargau' }, { code: 'AI', name: 'Appenzell Innerrhoden' }, + { code: 'AR', name: 'Appenzell Ausserrhoden' }, { code: 'BE', name: 'Bern' }, + { code: 'BL', name: 'Basel-Landschaft' }, { code: 'BS', name: 'Basel-Stadt' }, + { code: 'FR', name: 'Fribourg' }, { code: 'GE', name: 'Geneva' }, + { code: 'GL', name: 'Glarus' }, { code: 'GR', name: 'Graubünden' }, + { code: 'JU', name: 'Jura' }, { code: 'LU', name: 'Lucerne' }, + { code: 'NE', name: 'Neuchâtel' }, { code: 'NW', name: 'Nidwalden' }, + { code: 'OW', name: 'Obwalden' }, { code: 'SG', name: 'St. Gallen' }, + { code: 'SH', name: 'Schaffhausen' }, { code: 'SO', name: 'Solothurn' }, + { code: 'SZ', name: 'Schwyz' }, { code: 'TG', name: 'Thurgau' }, + { code: 'TI', name: 'Ticino' }, { code: 'UR', name: 'Uri' }, + { code: 'VD', name: 'Vaud' }, { code: 'VS', name: 'Valais' }, + { code: 'ZG', name: 'Zug' }, { code: 'ZH', name: 'Zürich' }, +]; + +interface HolidayDef { + name: string; + month?: number; + day?: number; + easterOffset?: number; + cantons: string[]; +} + +// Easter Sunday dates (Gregorian) — pre-computed for 2024–2030 +const EASTER: Record = { + 2024: [3, 31], 2025: [4, 20], 2026: [4, 5], + 2027: [3, 28], 2028: [4, 16], 2029: [4, 1], 2030: [4, 21], +}; + +const HOLIDAY_DEFS: HolidayDef[] = [ + { name: 'New Year', month: 1, day: 1, cantons: ['ALL'] }, + { name: 'Berchtoldstag', month: 1, day: 2, cantons: ['ZH', 'BE', 'LU', 'OW', 'GL', 'ZG', 'FR', 'SO', 'SH', 'TG', 'VD', 'NE', 'GE'] }, + { name: 'Heilige Drei Könige', month: 1, day: 6, cantons: ['UR', 'SZ', 'GR', 'TI', 'VS'] }, + { name: 'Josefstag', month: 3, day: 19, cantons: ['UR', 'SZ', 'NW', 'ZG', 'GR', 'TI', 'VS', 'LU'] }, + { name: 'Good Friday', easterOffset: -2, cantons: ['ZH', 'BE', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'FR', 'SO', 'BS', 'BL', 'SH', 'AR', 'AI', 'SG', 'GR', 'AG', 'TG', 'NE', 'GE', 'JU'] }, + { name: 'Easter Sunday', easterOffset: 0, cantons: ['ALL'] }, + { name: 'Easter Monday', easterOffset: 1, cantons: ['ALL'] }, + { name: 'Tag der Arbeit', month: 5, day: 1, cantons: ['ZH', 'BS', 'BL', 'SH', 'TG', 'TI', 'NE', 'JU', 'SO', 'AG', 'GR'] }, + { name: 'Ascension Day', easterOffset: 39, cantons: ['ALL'] }, + { name: 'Whit Sunday', easterOffset: 49, cantons: ['ALL'] }, + { name: 'Whit Monday', easterOffset: 50, cantons: ['ALL'] }, + { name: 'Corpus Christi', easterOffset: 60, cantons: ['LU', 'UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'SO', 'AI', 'AG', 'TI', 'VS', 'NE', 'JU'] }, + { name: 'National Day', month: 8, day: 1, cantons: ['ALL'] }, + { name: 'Mariä Himmelfahrt', month: 8, day: 15, cantons: ['UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'AI', 'AG', 'TI', 'VS', 'JU'] }, + { name: 'All Saints', month: 11, day: 1, cantons: ['UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'FR', 'SO', 'AI', 'SG', 'GR', 'AG', 'TI', 'VS', 'NE', 'JU'] }, + { name: 'Mariä Empfängnis', month: 12, day: 8, cantons: ['UR', 'SZ', 'OW', 'NW', 'ZG', 'FR', 'AI', 'AG', 'TI', 'VS', 'JU'] }, + { name: 'Christmas', month: 12, day: 25, cantons: ['ALL'] }, + { name: "St. Stephen's Day", month: 12, day: 26, cantons: ['ZH', 'BE', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'ZG', 'BS', 'BL', 'SH', 'AR', 'AI', 'SG', 'GR', 'AG', 'TG', 'TI', 'VS', 'NE'] }, + { name: 'Restauration de Genève', month: 12, day: 12, cantons: ['GE'] }, +]; + +// School holiday ranges per canton. Format: { name, start: [month,day], end: [month,day] } +// Fallback data (used when OpenHolidays API is unavailable). Approximate for most cantons. +interface SchoolHolidayDef { + name: string; + start: [number, number]; // [month, day] + end: [number, number]; + cantons: string[]; + yearOffset?: number; // end year = start year + yearOffset (default 0) +} + +const SCHOOL_HOLIDAYS_BY_YEAR: Record = { + 2025: [ + { name: 'Winter Holidays', start: [2, 10], end: [2, 21], cantons: ['ZH', 'AG', 'TG', 'SH', 'ZG'] }, + { name: 'Winter Holidays', start: [2, 3], end: [2, 14], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE'] }, + { name: 'Winter Holidays', start: [2, 17], end: [2, 28], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS', 'JU'] }, + { name: 'Spring Holidays', start: [4, 14], end: [4, 25], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG'] }, + { name: 'Spring Holidays', start: [4, 7], end: [4, 18], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU'] }, + { name: 'Spring Holidays', start: [4, 28], end: [5, 9], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS'] }, + { name: 'Summer Holidays', start: [7, 14], end: [8, 15], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'] }, + { name: 'Summer Holidays', start: [7, 7], end: [8, 8], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS'] }, + { name: 'Summer Holidays', start: [7, 21], end: [8, 22], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] }, + { name: 'Autumn Holidays', start: [10, 6], end: [10, 17], cantons: ['ZH', 'AG', 'SH', 'ZG', 'TG', 'GR', 'AR', 'AI', 'SG'] }, + { name: 'Autumn Holidays', start: [10, 13], end: [10, 24], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] }, + { name: 'Christmas Holidays', start: [12, 22], end: [1, 2], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'], yearOffset: 1 }, + { name: 'Christmas Holidays', start: [12, 24], end: [1, 9], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'], yearOffset: 1 }, + ], + 2026: [ + { name: 'Winter Holidays', start: [2, 9], end: [2, 20], cantons: ['ZH', 'AG', 'TG', 'SH', 'ZG'] }, + { name: 'Winter Holidays', start: [2, 2], end: [2, 13], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE'] }, + { name: 'Winter Holidays', start: [2, 16], end: [2, 27], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS', 'JU'] }, + { name: 'Spring Holidays', start: [4, 20], end: [5, 2], cantons: ['ZH'] }, + { name: 'Spring Holidays', start: [4, 13], end: [4, 24], cantons: ['AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG'] }, + { name: 'Spring Holidays', start: [4, 6], end: [4, 17], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU'] }, + { name: 'Spring Holidays', start: [4, 27], end: [5, 8], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'GR', 'TI', 'VS'] }, + { name: 'Summer Holidays', start: [7, 13], end: [8, 14], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'] }, + { name: 'Summer Holidays', start: [7, 6], end: [8, 7], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS'] }, + { name: 'Summer Holidays', start: [7, 20], end: [8, 21], cantons: ['BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] }, + { name: 'Autumn Holidays', start: [10, 5], end: [10, 16], cantons: ['ZH', 'AG', 'SH', 'ZG', 'TG', 'GR', 'AR', 'AI', 'SG'] }, + { name: 'Autumn Holidays', start: [10, 12], end: [10, 23], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'] }, + { name: 'Christmas Holidays', start: [12, 21], end: [1, 1], cantons: ['ZH', 'AG', 'SH', 'ZG', 'SG', 'AR', 'AI', 'TG', 'GR'], yearOffset: 1 }, + { name: 'Christmas Holidays', start: [12, 23], end: [1, 8], cantons: ['BE', 'FR', 'SO', 'NE', 'VD', 'GE', 'JU', 'VS', 'BS', 'BL', 'LU', 'UR', 'SZ', 'OW', 'NW', 'GL', 'TI'], yearOffset: 1 }, + ], +}; + +export interface CalendarEvent { + date: string; // 'YYYY-MM-DD' + title: string; + type: 'national' | 'canton' | 'school' | 'deadline' | 'expense'; + amount?: number; + id?: number; + color: string; +} + +function toDateStr(year: number, month: number, day: number): string { + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +function addDays(date: Date, days: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +} + +export function getHolidaysForYear(year: number, canton: string): CalendarEvent[] { + const events: CalendarEvent[] = []; + const easterEntry = EASTER[year]; + if (!easterEntry) return events; + const easter = new Date(year, easterEntry[0] - 1, easterEntry[1]); + + for (const def of HOLIDAY_DEFS) { + const applies = def.cantons.includes('ALL') || def.cantons.includes(canton); + if (!applies) continue; + + let date: Date; + if (def.easterOffset !== undefined) { + date = addDays(easter, def.easterOffset); + } else { + date = new Date(year, def.month! - 1, def.day!); + } + + if (date.getFullYear() !== year) continue; + + const isNational = def.cantons.includes('ALL'); + events.push({ + date: toDateStr(date.getFullYear(), date.getMonth() + 1, date.getDate()), + title: def.name, + type: isNational ? 'national' : 'canton', + color: isNational ? '#f97316' : '#ef4444', + }); + } + return events; +} + +export function getSchoolHolidaysForYear(year: number, canton: string): CalendarEvent[] { + const events: CalendarEvent[] = []; + const defs = SCHOOL_HOLIDAYS_BY_YEAR[year]; + if (!defs) return events; + + for (const def of defs) { + if (!def.cantons.includes(canton)) continue; + + const endYear = year + (def.yearOffset ?? 0); + const start = new Date(year, def.start[0] - 1, def.start[1]); + const end = new Date(endYear, def.end[0] - 1, def.end[1]); + + const cur = new Date(start); + while (cur <= end) { + if (cur.getFullYear() === year) { + events.push({ + date: toDateStr(cur.getFullYear(), cur.getMonth() + 1, cur.getDate()), + title: def.name, + type: 'school', + color: '#10b981', + }); + } + cur.setDate(cur.getDate() + 1); + } + } + return events; +} diff --git a/frontend/src/app/expenses/expense-list/expense-list.css b/frontend/src/app/expenses/expense-list/expense-list.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/expenses/expense-list/expense-list.html b/frontend/src/app/expenses/expense-list/expense-list.html new file mode 100644 index 0000000..a763918 --- /dev/null +++ b/frontend/src/app/expenses/expense-list/expense-list.html @@ -0,0 +1,342 @@ + +
+
+

{{ 'expenses.title' | translate }}

+

+ {{ 'expenses.total' | translate }} + {{ total() | number:'1.2-2' }} CHF +

+
+ +
+ + +
+
+ + + + + + + + + + + + + @for (expense of expenses(); track expense.id) { + + + + + + + + + } @empty { + + + + } + +
{{ 'expenses.col_date' | translate }}{{ 'expenses.col_name' | translate }}{{ 'expenses.col_amount' | translate }}Actions
{{ expense.date | date:'dd.MM.yyyy' }}{{ expense.name }}{{ expense.amount | number:'1.2-2' }} CHF +
+ + +
+
+ {{ 'expenses.no_expenses' | translate }} +
+
+
+ + + +@if (showNoAccountsModal()) { +
+
+
+
+ +
+
+
+ + + + +
+

{{ 'common.no_accounts_title' | translate }}

+
+ +
+ +

{{ 'common.no_accounts_text' | translate }}

+ +
+ + + {{ 'common.go_to_accounts' | translate }} + +
+ +
+
+
+} + + + +@if (showCreateModal()) { +
+
+
+
+ + +
+

{{ 'expenses.create_title' | translate }}

+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showEditModal()) { +
+
+
+
+ + +
+

{{ 'expenses.edit_title' | translate }}

+ +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showDeleteModal()) { +
+
+
+
+ +
+ + + + +
+ +

{{ 'common.delete_confirm_title' | translate }}

+

{{ 'common.delete_confirm_text' | translate }}

+ +
+ + +
+ +
+
+
+} diff --git a/frontend/src/app/expenses/expense-list/expense-list.ts b/frontend/src/app/expenses/expense-list/expense-list.ts new file mode 100644 index 0000000..591f19f --- /dev/null +++ b/frontend/src/app/expenses/expense-list/expense-list.ts @@ -0,0 +1,210 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; + +export const EXPENSE_CATEGORIES = [ + { key: 'groceries', label: 'expenses.categories.groceries' }, + { key: 'dining', label: 'expenses.categories.dining' }, + { key: 'transport', label: 'expenses.categories.transport' }, + { key: 'health', label: 'expenses.categories.health' }, + { key: 'clothing', label: 'expenses.categories.clothing' }, + { key: 'electronics', label: 'expenses.categories.electronics' }, + { key: 'household', label: 'expenses.categories.household' }, + { key: 'entertainment', label: 'expenses.categories.entertainment' }, + { key: 'travel', label: 'expenses.categories.travel' }, + { key: 'other', label: 'expenses.categories.other' }, +]; + +@Component({ + selector: 'app-expense-list', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule, TranslateModule], + templateUrl: './expense-list.html', + styleUrl: './expense-list.css', +}) +export class ExpenseList implements OnInit { + expenses = signal([]); + accounts = signal([]); + + categories = EXPENSE_CATEGORIES; + + // No Accounts Modal + showNoAccountsModal = signal(false); + + // Create Modal + showCreateModal = signal(false); + newName = ''; + newAmount = 0; + newDate = ''; + newCategory = 'other'; + newAccountId: number | null = null; + newNotes = ''; + newDueDate = ''; + + // Edit Modal + showEditModal = signal(false); + editId = 0; + + // Delete Modal + showDeleteModal = signal(false); + deleteTargetId = 0; + editName = ''; + editAmount = 0; + editDate = ''; + editCategory = 'other'; + editAccountId: number | null = null; + editNotes = ''; + editDueDate = ''; + + constructor(private api: ApiService) {} + + ngOnInit(): void { + this.loadExpenses(); + this.loadAccounts(); + } + + loadExpenses() { + this.api.getExpenses().subscribe({ + next: (data) => this.expenses.set(data), + error: (err) => console.error('Error:', err), + }); + } + + loadAccounts() { + this.api.getAccounts().subscribe({ + next: (data) => this.accounts.set(data.filter((a: any) => a.active)), + error: (err) => console.error('Error:', err), + }); + } + + total(): number { + return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0); + } + + categoryLabel(key: string): string { + return EXPENSE_CATEGORIES.find((c) => c.key === key)?.label ?? key; + } + + accountName(id: number): string { + return this.accounts().find((a) => a.id === id)?.name ?? '–'; + } + + categoryBadgeClass(key: string): string { + const map: Record = { + groceries: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', + dining: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', + transport: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', + health: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', + clothing: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300', + electronics: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300', + household: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', + entertainment: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300', + travel: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300', + other: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + }; + return map[key] ?? map['other']; + } + + closeNoAccountsModal() { + this.showNoAccountsModal.set(false); + } + + // Create + openCreateModal() { + if (this.accounts().length === 0) { + this.showNoAccountsModal.set(true); + return; + } + this.newName = ''; + this.newAmount = 0; + this.newDate = new Date().toISOString().split('T')[0]; + this.newCategory = 'other'; + this.newAccountId = this.accounts()[0].id; + this.newNotes = ''; + this.newDueDate = ''; + this.showCreateModal.set(true); + } + + closeCreateModal() { + this.showCreateModal.set(false); + } + + createExpense() { + if (!this.newName || !this.newAccountId || !this.newDate) return; + this.api.createExpense({ + name: this.newName, + amount: this.newAmount, + date: this.newDate, + category: this.newCategory, + account: this.newAccountId, + notes: this.newNotes, + due_date: this.newDueDate || null, + }).subscribe({ + next: () => { + this.loadExpenses(); + this.closeCreateModal(); + }, + error: (err) => console.error('Error creating expense:', err), + }); + } + + // Edit + openEditModal(expense: any) { + this.editId = expense.id; + this.editName = expense.name; + this.editAmount = expense.amount; + this.editDate = expense.date; + this.editCategory = expense.category; + this.editAccountId = expense.account; + this.editNotes = expense.notes; + this.editDueDate = expense.due_date ?? ''; + this.showEditModal.set(true); + } + + closeEditModal() { + this.showEditModal.set(false); + } + + updateExpense() { + if (!this.editName || !this.editAccountId || !this.editDate) return; + this.api.updateExpense(this.editId, { + name: this.editName, + amount: this.editAmount, + date: this.editDate, + category: this.editCategory, + account: this.editAccountId, + notes: this.editNotes, + due_date: this.editDueDate || null, + }).subscribe({ + next: () => { + this.loadExpenses(); + this.closeEditModal(); + }, + error: (err) => console.error('Error updating expense:', err), + }); + } + + // Delete + openDeleteModal(id: number) { + this.deleteTargetId = id; + this.showDeleteModal.set(true); + } + + closeDeleteModal() { + this.showDeleteModal.set(false); + this.deleteTargetId = 0; + } + + confirmDelete() { + this.api.deleteExpense(this.deleteTargetId).subscribe({ + next: () => { + this.loadExpenses(); + this.closeDeleteModal(); + }, + error: (err) => console.error('Error deleting expense:', err), + }); + } +} diff --git a/frontend/src/app/guards/auth.guard.ts b/frontend/src/app/guards/auth.guard.ts new file mode 100644 index 0000000..1c21a9c --- /dev/null +++ b/frontend/src/app/guards/auth.guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth'; + +export const authGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + if (auth.isLoggedIn()) { + return true; + } + return router.createUrlTree(['/login']); +}; diff --git a/frontend/src/app/interceptors/auth.interceptor.ts b/frontend/src/app/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..e118bd7 --- /dev/null +++ b/frontend/src/app/interceptors/auth.interceptor.ts @@ -0,0 +1,28 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { AuthService } from '../services/auth'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const auth = inject(AuthService); + const isInternal = req.url.startsWith('/api'); + const token = auth.getToken(); + const sessionKey = auth.getSessionKey(); + + const headers: Record = {}; + if (token && isInternal) headers['Authorization'] = `Bearer ${token}`; + if (sessionKey && isInternal) headers['X-Session-Key'] = sessionKey; + + const authReq = Object.keys(headers).length > 0 + ? req.clone({ setHeaders: headers }) + : req; + + return next(authReq).pipe( + catchError((err) => { + if (err.status === 401 && isInternal) { + auth.logout(); + } + return throwError(() => err); + }) + ); +}; diff --git a/frontend/src/app/layout/navbar/navbar.css b/frontend/src/app/layout/navbar/navbar.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/layout/navbar/navbar.html b/frontend/src/app/layout/navbar/navbar.html new file mode 100644 index 0000000..c810680 --- /dev/null +++ b/frontend/src/app/layout/navbar/navbar.html @@ -0,0 +1,219 @@ +
+ +
diff --git a/frontend/src/app/layout/navbar/navbar.spec.ts b/frontend/src/app/layout/navbar/navbar.spec.ts new file mode 100644 index 0000000..ea91146 --- /dev/null +++ b/frontend/src/app/layout/navbar/navbar.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Navbar } from './navbar'; + +describe('Navbar', () => { + let component: Navbar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Navbar], + }).compileComponents(); + + fixture = TestBed.createComponent(Navbar); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/layout/navbar/navbar.ts b/frontend/src/app/layout/navbar/navbar.ts new file mode 100644 index 0000000..ed0c83a --- /dev/null +++ b/frontend/src/app/layout/navbar/navbar.ts @@ -0,0 +1,133 @@ +import { Component, OnInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged, switchMap, of } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; +import { AuthService } from '../../services/auth'; +import { SidebarService } from '../../services/sidebar'; +import { ThemeService } from '../../services/theme'; +import { NotificationService, Notification } from '../../services/notification'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-navbar', + standalone: true, + imports: [CommonModule, RouterModule, TranslateModule], + templateUrl: './navbar.html', + styleUrl: './navbar.css', +}) +export class Navbar implements OnInit, OnDestroy { + firstName = signal(''); + lastName = signal(''); + email = signal(''); + avatarColor = signal('#1A56DB'); + avatarImageUrl = signal(null); + + notifOpen = false; + avatarDropdownOpen = false; + + // Search + searchQuery = signal(''); + searchResults = signal>({}); + searchOpen = signal(false); + private searchSubject = new Subject(); + private searchSub: any; + + readonly searchCategories: { key: string; labelKey: string; route: string; icon: string }[] = [ + { key: 'accounts', labelKey: 'search.accounts', route: '/accounts', icon: 'M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z' }, + { key: 'budgets', labelKey: 'search.budgets', route: '/budgets', icon: 'M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9z' }, + { key: 'expenses', labelKey: 'search.expenses', route: '/expenses', icon: 'M10 2a8 8 0 100 16A8 8 0 0010 2zm1 11H9v-2h2v2zm0-4H9V5h2v4z' }, + { key: 'transactions', labelKey: 'search.transactions', route: '/transactions', icon: 'M8 5a1 1 0 000 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8z' }, + { key: 'deadlines', labelKey: 'search.deadlines', route: '/calendar', icon: 'M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1z' }, + ]; + + constructor( + private api: ApiService, + private auth: AuthService, + private router: Router, + public sidebarService: SidebarService, + public themeService: ThemeService, + public notifService: NotificationService, + ) {} + + openNotification(n: Notification) { + this.notifService.markRead(n); + const [year, month] = n.date.split('-').map(Number); + this.router.navigate(['/calendar'], { queryParams: { year, month } }); + this.notifOpen = false; + } + + logout(): void { + this.auth.logout(); + } + + ngOnDestroy(): void { + this.searchSub?.unsubscribe(); + } + + onSearchInput(value: string) { + this.searchQuery.set(value); + if (value.length < 2) { + this.searchResults.set({}); + this.searchOpen.set(false); + return; + } + this.searchSubject.next(value); + this.searchOpen.set(true); + } + + navigateToResult(category: { key: string; route: string }, item: any) { + this.searchOpen.set(false); + this.searchQuery.set(''); + this.searchResults.set({}); + if (category.key === 'deadlines') { + const [year, month] = item.date.split('-').map(Number); + this.router.navigate([category.route], { queryParams: { year, month } }); + } else { + this.router.navigate([category.route]); + } + } + + closeSearch() { + this.searchOpen.set(false); + this.searchQuery.set(''); + this.searchResults.set({}); + } + + hasResults(): boolean { + return Object.keys(this.searchResults()).length > 0; + } + + ngOnInit(): void { + this.searchSub = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(q => q.length >= 2 ? this.api.search(q) : of({})) + ).subscribe(results => this.searchResults.set(results)); + + this.api.getProfile().subscribe({ + next: (data) => { + this.firstName.set(data.first_name); + this.lastName.set(data.last_name); + this.email.set(data.email); + this.avatarColor.set(data.avatar_color); + if (data.avatar_image) { + this.avatarImageUrl.set(data.avatar_image); + } + }, + }); + } + + initials(): string { + const f = this.firstName().trim(); + const l = this.lastName().trim(); + if (f && l) return (f[0] + l[0]).toUpperCase(); + if (f) return f[0].toUpperCase(); + return '?'; + } + + fullName(): string { + return `${this.firstName()} ${this.lastName()}`.trim() || 'My Account'; + } +} diff --git a/frontend/src/app/layout/shell/shell.html b/frontend/src/app/layout/shell/shell.html new file mode 100644 index 0000000..51698c7 --- /dev/null +++ b/frontend/src/app/layout/shell/shell.html @@ -0,0 +1,16 @@ + + + +@if (sidebarService.mobileOpen()) { +
+
+} + +
+ +
+ +
+
diff --git a/frontend/src/app/layout/shell/shell.ts b/frontend/src/app/layout/shell/shell.ts new file mode 100644 index 0000000..d504a30 --- /dev/null +++ b/frontend/src/app/layout/shell/shell.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { Navbar } from '../navbar/navbar'; +import { Sidebar } from '../sidebar/sidebar'; +import { SidebarService } from '../../services/sidebar'; +import { NotificationService } from '../../services/notification'; +import { ApiService } from '../../services/api'; +import { LanguageService } from '../../services/language'; + +@Component({ + selector: 'app-shell', + standalone: true, + imports: [RouterOutlet, Navbar, Sidebar], + templateUrl: './shell.html', +}) +export class Shell implements OnInit { + constructor( + public sidebarService: SidebarService, + private notifications: NotificationService, + private api: ApiService, + private langService: LanguageService, + ) {} + + ngOnInit(): void { + this.notifications.start(); + // Load language from profile, then fall back to localStorage / browser + this.api.getProfile().subscribe({ + next: (data) => this.langService.init(data.language), + error: () => this.langService.init(), + }); + } +} diff --git a/frontend/src/app/layout/sidebar/sidebar.css b/frontend/src/app/layout/sidebar/sidebar.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/layout/sidebar/sidebar.html b/frontend/src/app/layout/sidebar/sidebar.html new file mode 100644 index 0000000..a064177 --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.html @@ -0,0 +1,270 @@ + +@if (sidebarService.openFlyout()) { +
+} + + diff --git a/frontend/src/app/layout/sidebar/sidebar.ts b/frontend/src/app/layout/sidebar/sidebar.ts new file mode 100644 index 0000000..55deaa1 --- /dev/null +++ b/frontend/src/app/layout/sidebar/sidebar.ts @@ -0,0 +1,63 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { RouterLink, RouterLinkActive, Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { SidebarService } from '../../services/sidebar'; +import { ThemeService } from '../../services/theme'; +import { NotificationService, Notification } from '../../services/notification'; +import { AuthService } from '../../services/auth'; +import { ApiService } from '../../services/api'; + +@Component({ + selector: 'app-sidebar', + standalone: true, + imports: [RouterLink, RouterLinkActive, CommonModule, TranslateModule], + templateUrl: './sidebar.html', + styleUrl: './sidebar.css', +}) +export class Sidebar implements OnInit { + notifOpen = false; + private firstName = signal(''); + private lastName = signal(''); + avatarColor = signal('#1A56DB'); + avatarImageUrl = signal(null); + + constructor( + public sidebarService: SidebarService, + public themeService: ThemeService, + public notifService: NotificationService, + private auth: AuthService, + private api: ApiService, + private router: Router, + ) {} + + ngOnInit() { + this.api.getProfile().subscribe({ + next: (data) => { + this.firstName.set(data.first_name); + this.lastName.set(data.last_name); + this.avatarColor.set(data.avatar_color); + if (data.avatar_image) this.avatarImageUrl.set(data.avatar_image); + }, + }); + } + + initials(): string { + const f = this.firstName().trim(); + const l = this.lastName().trim(); + if (f && l) return (f[0] + l[0]).toUpperCase(); + if (f) return f[0].toUpperCase(); + return '?'; + } + + openNotification(n: Notification) { + this.notifService.markRead(n); + const [year, month] = n.date.split('-').map(Number); + this.router.navigate(['/calendar'], { queryParams: { year, month } }); + this.sidebarService.closeMobile(); + } + + logout() { + this.auth.logout(); + } +} diff --git a/frontend/src/app/profile/profile.css b/frontend/src/app/profile/profile.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/profile/profile.html b/frontend/src/app/profile/profile.html new file mode 100644 index 0000000..8127cdd --- /dev/null +++ b/frontend/src/app/profile/profile.html @@ -0,0 +1,183 @@ +
+ + +
+

{{ 'profile.title' | translate }}

+

{{ 'profile.subtitle' | translate }}

+
+ + +
+

{{ 'profile.personal_info' | translate }}

+ + +
+
+ @if (avatarImageUrl()) { + Profile photo + } @else { +
+ {{ initials() }} +
+ } +
+ + + + +
+ +
+ +
+

{{ 'profile.profile_photo' | translate }}

+

{{ 'profile.photo_hint' | translate }}

+

{{ 'profile.fallback_color' | translate }}

+
+ @for (color of avatarColors; track color) { + + } +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + @if (saveSuccess()) { +
+ + + + + {{ 'profile.save_success' | translate }} +
+ } + + +
+ + +
+

{{ 'profile.change_password' | translate }}

+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + @if (passwordError()) { +
+ + + + + {{ passwordError() }} +
+ } + @if (passwordSuccess()) { +
+ + + + + {{ 'profile.password_success' | translate }} +
+ } + + +
+ +
diff --git a/frontend/src/app/profile/profile.ts b/frontend/src/app/profile/profile.ts new file mode 100644 index 0000000..be479de --- /dev/null +++ b/frontend/src/app/profile/profile.ts @@ -0,0 +1,138 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../services/api'; +import { AuthService } from '../services/auth'; +import { LanguageService } from '../services/language'; +import { TranslateService } from '@ngx-translate/core'; +import { CANTONS, Canton } from '../data/swiss-holidays'; + +export const AVATAR_COLORS = [ + '#1A56DB', '#057A55', '#9061F9', '#E74694', + '#FF5A1F', '#0694A2', '#C27803', '#1C64F2', +]; + +export const SUPPORTED_LANGUAGES = [ + { code: 'de', labelKey: 'profile.languages.de' }, + { code: 'fr', labelKey: 'profile.languages.fr' }, + { code: 'it', labelKey: 'profile.languages.it' }, + { code: 'en', labelKey: 'profile.languages.en' }, +]; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './profile.html', + styleUrl: './profile.css', +}) +export class Profile implements OnInit { + avatarColors = AVATAR_COLORS; + cantons: Canton[] = CANTONS; + canton = signal('ZH'); + language = signal('de'); + languages = SUPPORTED_LANGUAGES; + + firstName = signal(''); + lastName = signal(''); + email = signal(''); + avatarColor = signal('#1A56DB'); + avatarImageUrl = signal(null); + selectedImageFile: File | null = null; + + newPassword = ''; + confirmPassword = ''; + passwordError = signal(''); + passwordSuccess = signal(false); + showNewPassword = signal(false); + showConfirmPassword = signal(false); + + saveSuccess = signal(false); + + + constructor( + private api: ApiService, + private auth: AuthService, + private langService: LanguageService, + private translate: TranslateService, + ) {} + + ngOnInit(): void { + this.api.getProfile().subscribe({ + next: (data) => { + this.firstName.set(data.first_name); + this.lastName.set(data.last_name); + this.email.set(data.email); + this.avatarColor.set(data.avatar_color); + if (data.canton) this.canton.set(data.canton); + if (data.language) this.language.set(data.language); + if (data.avatar_image) { + this.avatarImageUrl.set(data.avatar_image); + } + }, + }); + } + + onImageSelected(event: Event): void { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + this.selectedImageFile = file; + const reader = new FileReader(); + reader.onload = (e) => this.avatarImageUrl.set(e.target?.result as string); + reader.readAsDataURL(file); + } + + initials(): string { + const f = this.firstName().trim(); + const l = this.lastName().trim(); + if (f && l) return (f[0] + l[0]).toUpperCase(); + if (f) return f[0].toUpperCase(); + return '?'; + } + + saveProfile(): void { + const data: any = { + first_name: this.firstName(), + last_name: this.lastName(), + email: this.email(), + avatar_color: this.avatarColor(), + canton: this.canton(), + language: this.language(), + }; + if (this.selectedImageFile) { + data['avatar_image'] = this.selectedImageFile; + } + this.api.updateProfile(data).subscribe({ + next: (res) => { + if (res.avatar_image) { + this.avatarImageUrl.set(res.avatar_image); + } + this.selectedImageFile = null; + this.langService.setLanguage(this.language()); + this.saveSuccess.set(true); + setTimeout(() => this.saveSuccess.set(false), 3000); + }, + }); + } + + savePassword(): void { + this.passwordError.set(''); + const t = (k: string) => this.translate.instant(k); + if (!this.newPassword) { this.passwordError.set(t('profile.errors.password_empty')); return; } + if (this.newPassword !== this.confirmPassword) { this.passwordError.set(t('profile.errors.passwords_mismatch')); return; } + if (this.newPassword.length < 8) { this.passwordError.set(t('profile.errors.password_too_short')); return; } + this.api.changePassword(this.newPassword).subscribe({ + next: () => { + this.passwordSuccess.set(true); + this.newPassword = ''; + this.confirmPassword = ''; + this.showNewPassword.set(false); + this.showConfirmPassword.set(false); + setTimeout(() => this.passwordSuccess.set(false), 3000); + }, + error: () => this.passwordError.set(this.translate.instant('profile.errors.password_failed')), + }); + } + +} diff --git a/frontend/src/app/services/api.spec.ts b/frontend/src/app/services/api.spec.ts new file mode 100644 index 0000000..5824fb1 --- /dev/null +++ b/frontend/src/app/services/api.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Api } from './api'; + +describe('Api', () => { + let service: Api; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Api); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/services/api.ts b/frontend/src/app/services/api.ts new file mode 100644 index 0000000..7b56356 --- /dev/null +++ b/frontend/src/app/services/api.ts @@ -0,0 +1,197 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + private baseUrl = '/api'; + + constructor(private http: HttpClient) { } + + // Accounts + getAccounts(): Observable { + return this.http.get(`${this.baseUrl}/accounts/`); + } + + createAccount(account: {name: string, balance: number, account_type: string}): Observable { + return this.http.post(`${this.baseUrl}/accounts/`, account); + } + + updateAccount(id: number, account: {name: string, balance: number, account_type: string}): Observable { + return this.http.put(`${this.baseUrl}/accounts/${id}/`, account); + } + + deleteAccount(id: number): Observable { + return this.http.delete(`${this.baseUrl}/accounts/${id}/`); + } + + // Budgets + getBudgets(): Observable { + return this.http.get(`${this.baseUrl}/budgets/`); + } + + createBudget(budget: any): Observable { + return this.http.post(`${this.baseUrl}/budgets/`, budget); + } + + updateBudget(id: number, budget: any): Observable { + return this.http.put(`${this.baseUrl}/budgets/${id}/`, budget); + } + + deleteBudget(id: number): Observable { + return this.http.delete(`${this.baseUrl}/budgets/${id}/`); + } + + // Transactions + getTransactions(): Observable { + return this.http.get(`${this.baseUrl}/transactions/`); + } + + createTransaction(transaction: any): Observable { + return this.http.post(`${this.baseUrl}/transactions/`, transaction); + } + + updateTransaction(id: number, transaction: any): Observable { + return this.http.put(`${this.baseUrl}/transactions/${id}/`, transaction); + } + + deleteTransaction(id: number): Observable { + return this.http.delete(`${this.baseUrl}/transactions/${id}/`); + } + + // Expenses + getExpenses(): Observable { + return this.http.get(`${this.baseUrl}/expenses/`); + } + + createExpense(expense: any): Observable { + return this.http.post(`${this.baseUrl}/expenses/`, expense); + } + + updateExpense(id: number, expense: any): Observable { + return this.http.put(`${this.baseUrl}/expenses/${id}/`, expense); + } + + deleteExpense(id: number): Observable { + return this.http.delete(`${this.baseUrl}/expenses/${id}/`); + } + + // Profile + getProfile(): Observable { + return this.http.get(`${this.baseUrl}/profile/`); + } + + updateProfile(data: any): Observable { + const formData = new FormData(); + Object.keys(data).forEach((key) => { + if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); + } + }); + return this.http.put(`${this.baseUrl}/profile/`, formData); + } + + deleteProfile(password: string): Observable { + return this.http.delete(`${this.baseUrl}/profile/`, { body: { password } }); + } + + changePassword(password: string): Observable { + return this.http.post(`${this.baseUrl}/auth/password/`, { password }); + } + + // Deadlines + getDeadlines(): Observable { + return this.http.get(`${this.baseUrl}/deadlines/`); + } + + createDeadline(d: any): Observable { + return this.http.post(`${this.baseUrl}/deadlines/`, d); + } + + updateDeadline(id: number, d: any): Observable { + return this.http.put(`${this.baseUrl}/deadlines/${id}/`, d); + } + + deleteDeadline(id: number): Observable { + return this.http.delete(`${this.baseUrl}/deadlines/${id}/`); + } + + getICalUrl(): Observable<{ url: string }> { + return this.http.get<{ url: string }>(`${this.baseUrl}/calendar/ical-url/`); + } + + search(q: string): Observable> { + return this.http.get>(`${this.baseUrl}/search/?q=${encodeURIComponent(q)}`); + } + + getNotifications(): Observable { + return this.http.get(`${this.baseUrl}/notifications/`); + } + + markNotificationRead(event_type: string, event_id: number): Observable { + return this.http.post(`${this.baseUrl}/notifications/`, { event_type, event_id }); + } + + // 2FA + get2FASetup(): Observable<{ secret: string; uri: string }> { + return this.http.get<{ secret: string; uri: string }>(`${this.baseUrl}/auth/2fa/setup/`); + } + + enable2FA(code: string): Observable { + return this.http.post(`${this.baseUrl}/auth/2fa/enable/`, { code }); + } + + disable2FA(code: string): Observable { + return this.http.post(`${this.baseUrl}/auth/2fa/disable/`, { code }); + } + + login2FA(temp_token: string, code: string): Observable<{ access: string; refresh: string; session_key?: string }> { + return this.http.post<{ access: string; refresh: string; session_key?: string }>(`${this.baseUrl}/auth/2fa/login/`, { temp_token, code }); + } + + request2FARecovery(temp_token: string): Observable { + return this.http.post(`${this.baseUrl}/auth/2fa/recover/`, { temp_token }); + } + + confirm2FARecovery(temp_token: string, recovery_code: string): Observable<{ access: string; refresh: string; session_key?: string }> { + return this.http.post<{ access: string; refresh: string; session_key?: string }>(`${this.baseUrl}/auth/2fa/recover/confirm/`, { temp_token, recovery_code }); + } + + // Sessions + getSessions(): Observable { + return this.http.get(`${this.baseUrl}/auth/sessions/`); + } + + revokeSession(sessionKey: string): Observable { + return this.http.delete(`${this.baseUrl}/auth/sessions/${sessionKey}/`); + } + + revokeAllOtherSessions(): Observable { + return this.http.delete(`${this.baseUrl}/auth/sessions/revoke-all/`); + } + + // Data export + downloadExport(): Observable { + return this.http.get(`${this.baseUrl}/export/`, { responseType: 'blob' }); + } + + // Notification preferences + updateNotificationPrefs(prefs: { notif_deadlines?: boolean; notif_budget_alerts?: boolean; notif_monthly_summary?: boolean }): Observable { + return this.http.patch(`${this.baseUrl}/notifications/prefs/`, prefs); + } + + // Email verification & password reset + verifyEmail(token: string): Observable { + return this.http.post(`${this.baseUrl}/auth/verify-email/`, { token }); + } + + requestPasswordReset(email: string): Observable { + return this.http.post(`${this.baseUrl}/auth/password-reset/`, { email }); + } + + confirmPasswordReset(token: string, password: string): Observable { + return this.http.post(`${this.baseUrl}/auth/password-reset/confirm/`, { token, password }); + } +} \ No newline at end of file diff --git a/frontend/src/app/services/auth.ts b/frontend/src/app/services/auth.ts new file mode 100644 index 0000000..4282dce --- /dev/null +++ b/frontend/src/app/services/auth.ts @@ -0,0 +1,83 @@ +import { Injectable, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly BASE = '/api/auth'; + private readonly ACCESS_KEY = 'access_token'; + private readonly REFRESH_KEY = 'refresh_token'; + private readonly SESSION_KEY = 'session_key'; + + isLoggedIn = signal(!!this.getToken()); + + constructor(private http: HttpClient, private router: Router) {} + + login(email: string, password: string, keepSignedIn: boolean, turnstileToken = ''): Observable { + return this.http.post(`${this.BASE}/token/`, { username: email, password, cf_turnstile_response: turnstileToken }).pipe( + tap((res) => { + if (res.access) { + this.storeTokens(res.access, res.refresh, keepSignedIn, res.session_key); + this.isLoggedIn.set(true); + } + }) + ); + } + + completeLogin(access: string, refresh: string, keepSignedIn: boolean, sessionKey?: string): void { + this.storeTokens(access, refresh, keepSignedIn, sessionKey); + this.isLoggedIn.set(true); + } + + register(email: string, password: string, turnstileToken = ''): Observable { + return this.http.post(`${this.BASE}/register/`, { email, password, cf_turnstile_response: turnstileToken }); + } + + logout(): void { + const refresh = this.getRefreshToken(); + if (refresh) { + this.http.post(`${this.BASE}/logout/`, { refresh }).subscribe({ error: () => {} }); + } + localStorage.removeItem(this.ACCESS_KEY); + localStorage.removeItem(this.REFRESH_KEY); + localStorage.removeItem(this.SESSION_KEY); + sessionStorage.removeItem(this.ACCESS_KEY); + sessionStorage.removeItem(this.REFRESH_KEY); + sessionStorage.removeItem(this.SESSION_KEY); + this.isLoggedIn.set(false); + this.router.navigate(['/login']); + } + + getToken(): string | null { + return sessionStorage.getItem(this.ACCESS_KEY) ?? localStorage.getItem(this.ACCESS_KEY); + } + + getSessionKey(): string | null { + return sessionStorage.getItem(this.SESSION_KEY) ?? localStorage.getItem(this.SESSION_KEY); + } + + refreshToken(): Observable { + const refresh = this.getRefreshToken(); + const keepSignedIn = !!localStorage.getItem(this.REFRESH_KEY); + return this.http.post(`${this.BASE}/token/refresh/`, { refresh }).pipe( + tap((tokens) => { + this.storeTokens(tokens.access, tokens.refresh, keepSignedIn); + }) + ); + } + + private storeTokens(access: string, refresh: string, keepSignedIn: boolean, sessionKey?: string): void { + const store = keepSignedIn ? localStorage : sessionStorage; + store.setItem(this.ACCESS_KEY, access); + store.setItem(this.REFRESH_KEY, refresh); + if (sessionKey) { + store.setItem(this.SESSION_KEY, sessionKey); + } + } + + private getRefreshToken(): string | null { + return sessionStorage.getItem(this.REFRESH_KEY) ?? localStorage.getItem(this.REFRESH_KEY); + } +} diff --git a/frontend/src/app/services/holidays.ts b/frontend/src/app/services/holidays.ts new file mode 100644 index 0000000..d6c072c --- /dev/null +++ b/frontend/src/app/services/holidays.ts @@ -0,0 +1,91 @@ +// School holidays and public holidays data via OpenHolidays API (openholidaysapi.org) +// License: AGPL-3.0 — see https://github.com/openpotato/openholidaysapi + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { CalendarEvent, getHolidaysForYear, getSchoolHolidaysForYear } from '../data/swiss-holidays'; + +const BASE_URL = 'https://openholidaysapi.org'; + +@Injectable({ providedIn: 'root' }) +export class HolidaysService { + private cache = new Map(); + + constructor(private http: HttpClient) {} + + getPublicHolidays(year: number, canton: string, lang = 'de'): Observable { + const apiLang = lang.toUpperCase(); + const key = `pub-${year}-${canton}-${apiLang}`; + if (this.cache.has(key)) return of(this.cache.get(key)!); + + const url = `${BASE_URL}/PublicHolidays?countryIsoCode=CH&subdivisionCode=CH-${canton}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${apiLang}`; + return this.http.get(url).pipe( + map(data => this.mapPublicHolidays(data, year, apiLang)), + tap(events => this.cache.set(key, events)), + catchError(() => of(getHolidaysForYear(year, canton))) + ); + } + + getSchoolHolidays(year: number, canton: string, lang = 'de'): Observable { + const apiLang = lang.toUpperCase(); + const key = `school-${year}-${canton}-${apiLang}`; + if (this.cache.has(key)) return of(this.cache.get(key)!); + + const url = `${BASE_URL}/SchoolHolidays?countryIsoCode=CH&subdivisionCode=CH-${canton}&validFrom=${year}-01-01&validTo=${year}-12-31&languageIsoCode=${apiLang}`; + return this.http.get(url).pipe( + map(data => this.mapSchoolHolidays(data, year, apiLang)), + tap(events => this.cache.set(key, events)), + catchError(() => of(getSchoolHolidaysForYear(year, canton))) + ); + } + + private toDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + private expandDays(startDate: string, endDate: string, year: number): Date[] { + const days: Date[] = []; + const cur = new Date(startDate); + const end = new Date(endDate); + while (cur <= end) { + if (cur.getFullYear() === year) days.push(new Date(cur)); + cur.setDate(cur.getDate() + 1); + } + return days; + } + + private mapPublicHolidays(data: any[], year: number, lang: string): CalendarEvent[] { + const events: CalendarEvent[] = []; + for (const h of data) { + const name = h.name?.find((n: any) => n.language === lang)?.text ?? h.name?.find((n: any) => n.language === 'DE')?.text ?? h.name?.[0]?.text ?? ''; + const isNational = h.nationwide === true; + for (const d of this.expandDays(h.startDate, h.endDate, year)) { + events.push({ + date: this.toDateStr(d), + title: name, + type: isNational ? 'national' : 'canton', + color: isNational ? '#f97316' : '#ef4444', + }); + } + } + return events; + } + + private mapSchoolHolidays(data: any[], year: number, lang: string): CalendarEvent[] { + const events: CalendarEvent[] = []; + for (const h of data) { + const name = h.name?.find((n: any) => n.language === lang)?.text ?? h.name?.find((n: any) => n.language === 'DE')?.text ?? h.name?.[0]?.text ?? ''; + for (const d of this.expandDays(h.startDate, h.endDate, year)) { + events.push({ + date: this.toDateStr(d), + title: name, + type: 'school', + color: '#10b981', + }); + } + } + return events; + } +} diff --git a/frontend/src/app/services/language.ts b/frontend/src/app/services/language.ts new file mode 100644 index 0000000..f942c0f --- /dev/null +++ b/frontend/src/app/services/language.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +const SUPPORTED_LANGS = ['de', 'fr', 'it', 'en']; +const STORAGE_KEY = 'app_language'; + +@Injectable({ providedIn: 'root' }) +export class LanguageService { + constructor(private translate: TranslateService) { + translate.addLangs(SUPPORTED_LANGS); + translate.setDefaultLang('de'); + // Load the persisted or browser-detected language immediately so + // translations are available before any component calls init(). + const stored = localStorage.getItem(STORAGE_KEY); + const initial = stored ?? this.detectBrowserLanguage(); + translate.use(initial); + } + + /** Call once at app startup (e.g. in AppComponent or Shell). */ + init(profileLanguage?: string): void { + const lang = profileLanguage + ?? localStorage.getItem(STORAGE_KEY) + ?? this.detectBrowserLanguage() + ?? 'de'; + this.applyLanguage(lang); + } + + /** Used on the register page to pre-select the browser language. */ + detectBrowserLanguage(): string { + const raw = navigator.language?.split('-')[0].toLowerCase(); + return SUPPORTED_LANGS.includes(raw) ? raw : 'de'; + } + + setLanguage(lang: string): void { + if (!SUPPORTED_LANGS.includes(lang)) return; + this.applyLanguage(lang); + localStorage.setItem(STORAGE_KEY, lang); + } + + get current(): string { + return this.translate.currentLang || 'de'; + } + + private applyLanguage(lang: string): void { + const safe = SUPPORTED_LANGS.includes(lang) ? lang : 'de'; + this.translate.use(safe); + } +} diff --git a/frontend/src/app/services/notification.ts b/frontend/src/app/services/notification.ts new file mode 100644 index 0000000..112682a --- /dev/null +++ b/frontend/src/app/services/notification.ts @@ -0,0 +1,55 @@ +import { Injectable, signal, OnDestroy } from '@angular/core'; +import { ApiService } from './api'; + +export interface Notification { + event_type: 'deadline' | 'expense'; + event_id: number; + title: string; + date: string; +} + +@Injectable({ providedIn: 'root' }) +export class NotificationService implements OnDestroy { + notifications = signal([]); + private intervalId: any; + + constructor(private api: ApiService) {} + + start() { + this.load(); + this.intervalId = setInterval(() => this.load(), 60_000); + } + + stop() { + clearInterval(this.intervalId); + } + + ngOnDestroy() { + this.stop(); + } + + private load() { + this.api.getNotifications().subscribe({ + next: (data) => this.notifications.set(data), + error: () => {}, + }); + } + + markRead(notification: Notification) { + this.api.markNotificationRead(notification.event_type, notification.event_id).subscribe({ + next: () => { + this.notifications.update(list => + list.filter(n => !(n.event_type === notification.event_type && n.event_id === notification.event_id)) + ); + }, + }); + } + + markAllRead() { + const current = this.notifications(); + current.forEach(n => + this.api.markNotificationRead(n.event_type, n.event_id).subscribe() + ); + this.notifications.set([]); + } +} diff --git a/frontend/src/app/services/sidebar.spec.ts b/frontend/src/app/services/sidebar.spec.ts new file mode 100644 index 0000000..9167d2e --- /dev/null +++ b/frontend/src/app/services/sidebar.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Sidebar } from './sidebar'; + +describe('Sidebar', () => { + let service: Sidebar; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Sidebar); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/services/sidebar.ts b/frontend/src/app/services/sidebar.ts new file mode 100644 index 0000000..e5b01fc --- /dev/null +++ b/frontend/src/app/services/sidebar.ts @@ -0,0 +1,41 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class SidebarService { + collapsed = signal(false); + openFlyout = signal(null); + mobileOpen = signal(false); + budgetsOpen = signal(false); + accountsOpen = signal(false); + + toggle() { + this.collapsed.update(v => !v); + this.openFlyout.set(null); + } + + toggleMobile() { + this.mobileOpen.update(v => !v); + } + + closeMobile() { + this.mobileOpen.set(false); + } + + toggleBudgets() { + this.budgetsOpen.update(v => !v); + } + + toggleAccounts() { + this.accountsOpen.update(v => !v); + } + + toggleFlyout(name: string) { + this.openFlyout.update(current => current === name ? null : name); + } + + closeFlyout() { + this.openFlyout.set(null); + } +} diff --git a/frontend/src/app/services/theme.ts b/frontend/src/app/services/theme.ts new file mode 100644 index 0000000..12001cc --- /dev/null +++ b/frontend/src/app/services/theme.ts @@ -0,0 +1,33 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + isDark = signal(false); + + constructor() { + const saved = localStorage.getItem('theme'); + if (saved) { + this.isDark.set(saved === 'dark'); + } else { + this.isDark.set(window.matchMedia('(prefers-color-scheme: dark)').matches); + } + this.applyTheme(); + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + if (!localStorage.getItem('theme')) { + this.isDark.set(e.matches); + this.applyTheme(); + } + }); + } + + toggle(): void { + this.isDark.update(v => !v); + this.applyTheme(); + localStorage.setItem('theme', this.isDark() ? 'dark' : 'light'); + } + + private applyTheme(): void { + document.documentElement.classList.toggle('dark', this.isDark()); + } +} diff --git a/frontend/src/app/settings/settings.html b/frontend/src/app/settings/settings.html new file mode 100644 index 0000000..535f39e --- /dev/null +++ b/frontend/src/app/settings/settings.html @@ -0,0 +1,452 @@ +
+ + +
+

{{ 'nav.settings' | translate }}

+

{{ 'settings.subtitle' | translate }}

+
+ + +
+

{{ 'settings.recovery_email' | translate }}

+

{{ 'settings.recovery_email_hint' | translate }}

+ +
+ + +
+ + @if (recoveryEmailSaved()) { +
+ + + + {{ 'settings.recovery_email_saved' | translate }} +
+ } +
+ + +
+
+
+

{{ 'profile.totp_title' | translate }}

+

{{ 'profile.totp_subtitle' | translate }}

+
+ + {{ (totpEnabled() ? 'profile.totp_on' : 'profile.totp_off') | translate }} + +
+ + @if (totpSuccess()) { +
+ + + + {{ totpSuccess() | translate }} +
+ } + + @if (totpSetupStep() === 'idle') { + @if (!totpEnabled()) { + + } @else { + + } + } + + @if (totpSetupStep() === 'scan') { +
+

{{ 'profile.totp_scan_hint' | translate }}

+ @if (totpQrDataUrl()) { +
+ QR Code +
+ } +
+ + +
+ @if (totpError()) { +
+ + + + {{ totpError() | translate }} +
+ } +
+ + +
+
+ } + + @if (totpSetupStep() === 'disable') { +
+

{{ 'profile.totp_disable_hint' | translate }}

+
+ + +
+ @if (totpError()) { +
+ + + + {{ totpError() | translate }} +
+ } +
+ + +
+
+ } +
+ + +
+
+

{{ 'settings.sessions_title' | translate }}

+ @if (sessions().length > 1) { + + } +
+

{{ 'settings.sessions_hint' | translate }}

+ + @if (sessionsLoading()) { +

{{ 'settings.sessions_loading' | translate }}

+ } @else { +
+ @for (session of sessions(); track session.session_key) { +
+
+
+ + {{ session.device_name || ('settings.sessions_unknown_device' | translate) }} + + @if (session.is_current) { + + {{ 'settings.sessions_current' | translate }} + + } +
+

+ {{ session.ip_address }} + @if (session.ip_address) { · } + {{ session.last_active_at | date:'dd.MM.yyyy HH:mm' }} +

+
+ @if (!session.is_current) { + + } +
+ } +
+ } +
+ + +
+

{{ 'settings.export_title' | translate }}

+

{{ 'settings.export_hint' | translate }}

+ +
+ + +
+

{{ 'settings.notif_title' | translate }}

+

{{ 'settings.notif_hint' | translate }}

+ +
+
+
+

{{ 'settings.notif_deadlines' | translate }}

+

{{ 'settings.notif_deadlines_hint' | translate }}

+
+ +
+ +
+
+

{{ 'settings.notif_budget_alerts' | translate }}

+

{{ 'settings.notif_budget_alerts_hint' | translate }}

+
+ +
+ +
+
+

{{ 'settings.notif_monthly_summary' | translate }}

+

{{ 'settings.notif_monthly_summary_hint' | translate }}

+
+ +
+
+ +
+ + @if (notifSaved()) { + + + + + {{ 'settings.notif_saved' | translate }} + + } +
+
+ + +
+

{{ 'profile.danger_zone' | translate }}

+

{{ 'profile.danger_text' | translate }}

+ +
+ +
+ + + +@if (backupCodes().length > 0) { +
+
+
+
+ +
+
+ + + + +
+
+

{{ 'profile.backup_codes_title' | translate }}

+

{{ 'profile.backup_codes_hint' | translate }}

+
+
+ +
+ @for (code of backupCodes(); track code) { + {{ code }} + } +
+ +
+ + + +
+ +
+
+
+} + + + +@if (showDeleteModal()) { +
+
+
+
+ + @if (!exportedBeforeDelete()) { + +
+ + + + +
+

{{ 'settings.delete_step1_title' | translate }}

+

{{ 'settings.delete_step1_hint' | translate }}

+
+ + +
+ + } @else { + +
+ + + + +
+
+ + + + {{ 'settings.delete_step2_exported' | translate }} +
+

{{ 'profile.delete_account_confirm' | translate }}

+

{{ 'profile.delete_account_text' | translate }}

+ +
+
+ +
+ + +
+
+
+ + +
+ @if (deleteError()) { +
+ + + + {{ deleteError() | translate }} +
+ } +
+ +
+ + +
+ } + +
+
+
+} diff --git a/frontend/src/app/settings/settings.ts b/frontend/src/app/settings/settings.ts new file mode 100644 index 0000000..3c97183 --- /dev/null +++ b/frontend/src/app/settings/settings.ts @@ -0,0 +1,287 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import * as QRCode from 'qrcode'; +import { ApiService } from '../services/api'; +import { AuthService } from '../services/auth'; + +@Component({ + selector: 'app-settings', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './settings.html', +}) +export class Settings implements OnInit { + + // Recovery email + recoveryEmail = ''; + recoveryEmailSaved = signal(false); + + // 2FA + totpEnabled = signal(false); + totpSetupStep = signal<'idle' | 'scan' | 'disable'>('idle'); + totpQrDataUrl = signal(null); + totpCode = ''; + totpError = signal(''); + totpSuccess = signal(''); + backupCodes = signal([]); + backupCopied = signal(false); + + // Active Sessions + sessions = signal([]); + sessionsLoading = signal(false); + revokeLoading = signal(null); + revokeAllLoading = signal(false); + + // Data Export + exportLoading = signal(false); + exportedBeforeDelete = signal(false); + + // Notification Preferences + notifPrefs = signal({ notif_deadlines: true, notif_budget_alerts: true, notif_monthly_summary: false }); + notifSaving = signal(false); + notifSaved = signal(false); + + // Danger Zone + showDeleteModal = signal(false); + showDeletePassword = signal(false); + deletePassword = ''; + deleteConfirmText = ''; + deleteError = signal(''); + + get DELETE_PHRASE(): string { + return this.translate.instant('profile.delete_account'); + } + + constructor(private api: ApiService, private auth: AuthService, private translate: TranslateService) {} + + ngOnInit(): void { + this.api.getProfile().subscribe({ + next: (data) => { + if (data.totp_enabled !== undefined) this.totpEnabled.set(data.totp_enabled); + if (data.recovery_email !== undefined) this.recoveryEmail = data.recovery_email; + this.notifPrefs.set({ + notif_deadlines: data.notif_deadlines ?? true, + notif_budget_alerts: data.notif_budget_alerts ?? true, + notif_monthly_summary: data.notif_monthly_summary ?? false, + }); + }, + }); + this.loadSessions(); + } + + // ── Recovery email ──────────────────────────────────────────────────────── + + saveRecoveryEmail(): void { + this.api.updateProfile({ recovery_email: this.recoveryEmail }).subscribe({ + next: () => { + this.recoveryEmailSaved.set(true); + setTimeout(() => this.recoveryEmailSaved.set(false), 3000); + }, + }); + } + + // ── 2FA ────────────────────────────────────────────────────────────────── + + startEnable2FA(): void { + this.totpError.set(''); + this.totpCode = ''; + this.api.get2FASetup().subscribe({ + next: async (res) => { + const dataUrl = await QRCode.toDataURL(res.uri, { width: 200, margin: 2 }); + this.totpQrDataUrl.set(dataUrl); + this.totpSetupStep.set('scan'); + }, + }); + } + + confirmEnable2FA(): void { + this.totpError.set(''); + this.api.enable2FA(this.totpCode).subscribe({ + next: (res) => { + this.totpEnabled.set(true); + this.totpSetupStep.set('idle'); + this.totpQrDataUrl.set(null); + this.totpCode = ''; + this.backupCodes.set(res.backup_codes ?? []); + }, + error: () => this.totpError.set('profile.totp_invalid_code'), + }); + } + + startDisable2FA(): void { + this.totpError.set(''); + this.totpCode = ''; + this.totpSetupStep.set('disable'); + } + + confirmDisable2FA(): void { + this.totpError.set(''); + this.api.disable2FA(this.totpCode).subscribe({ + next: () => { + this.totpEnabled.set(false); + this.totpSetupStep.set('idle'); + this.totpCode = ''; + this.totpSuccess.set('profile.totp_disabled_success'); + setTimeout(() => this.totpSuccess.set(''), 3000); + }, + error: () => this.totpError.set('profile.totp_invalid_code'), + }); + } + + cancelTotp(): void { + this.totpSetupStep.set('idle'); + this.totpCode = ''; + this.totpError.set(''); + this.totpQrDataUrl.set(null); + } + + copyBackupCodes(): void { + navigator.clipboard.writeText(this.backupCodes().join('\n')).then(() => { + this.backupCopied.set(true); + setTimeout(() => this.backupCopied.set(false), 2000); + }); + } + + async downloadBackupCodesPdf(): Promise { + const { jsPDF } = await import('jspdf'); + const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + const codes = this.backupCodes(); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(18); + doc.text('Armarium — Backup Codes', 20, 24); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(100); + doc.text('Store these codes in a safe place. Each code can only be used once.', 20, 34); + doc.text('Use one if you lose access to your authenticator app.', 20, 40); + + doc.setDrawColor(200); + doc.line(20, 46, 190, 46); + + doc.setFontSize(14); + doc.setFont('courier', 'normal'); + doc.setTextColor(30); + codes.forEach((code, i) => { + doc.text(code, 20, 58 + i * 12); + }); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8); + doc.setTextColor(150); + doc.text(`Generated ${new Date().toLocaleDateString()} · armarium.app`, 20, 200); + + doc.save('armarium-backup-codes.pdf'); + } + + closeBackupCodes(): void { + this.backupCodes.set([]); + this.totpSuccess.set('profile.totp_enabled_success'); + setTimeout(() => this.totpSuccess.set(''), 3000); + } + + // ── Active Sessions ─────────────────────────────────────────────────────── + + loadSessions(): void { + this.sessionsLoading.set(true); + this.api.getSessions().subscribe({ + next: (s) => { this.sessions.set(s); this.sessionsLoading.set(false); }, + error: () => this.sessionsLoading.set(false), + }); + } + + revokeSession(key: string): void { + this.revokeLoading.set(key); + this.api.revokeSession(key).subscribe({ + next: () => { + this.sessions.set(this.sessions().filter(s => s.session_key !== key)); + this.revokeLoading.set(null); + }, + error: () => this.revokeLoading.set(null), + }); + } + + revokeAllOtherSessions(): void { + this.revokeAllLoading.set(true); + this.api.revokeAllOtherSessions().subscribe({ + next: () => { this.loadSessions(); this.revokeAllLoading.set(false); }, + error: () => this.revokeAllLoading.set(false), + }); + } + + // ── Data Export ─────────────────────────────────────────────────────────── + + downloadExport(): void { + this.exportLoading.set(true); + this.api.downloadExport().subscribe({ + next: (blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'armarium-export.zip'; + a.click(); + URL.revokeObjectURL(url); + this.exportLoading.set(false); + this.exportedBeforeDelete.set(true); + }, + error: () => this.exportLoading.set(false), + }); + } + + // ── Notification Preferences ────────────────────────────────────────────── + + toggleNotif(key: 'notif_deadlines' | 'notif_budget_alerts' | 'notif_monthly_summary'): void { + this.notifPrefs.set({ ...this.notifPrefs(), [key]: !this.notifPrefs()[key] }); + } + + saveNotifPrefs(): void { + this.notifSaving.set(true); + this.api.updateNotificationPrefs(this.notifPrefs()).subscribe({ + next: () => { + this.notifSaving.set(false); + this.notifSaved.set(true); + setTimeout(() => this.notifSaved.set(false), 3000); + }, + error: () => this.notifSaving.set(false), + }); + } + + // ── Danger Zone ─────────────────────────────────────────────────────────── + + get deleteFormValid(): boolean { + return !!this.deletePassword && this.deleteConfirmText === this.DELETE_PHRASE; + } + + openDeleteModal(): void { + this.deletePassword = ''; + this.deleteConfirmText = ''; + this.deleteError.set(''); + this.showDeletePassword.set(false); + this.showDeleteModal.set(true); + } + + closeDeleteModal(): void { + this.showDeleteModal.set(false); + this.deletePassword = ''; + this.deleteConfirmText = ''; + this.deleteError.set(''); + this.showDeletePassword.set(false); + } + + confirmDelete(): void { + if (!this.deleteFormValid) return; + this.deleteError.set(''); + this.api.deleteProfile(this.deletePassword).subscribe({ + next: () => { + this.closeDeleteModal(); + localStorage.clear(); + sessionStorage.clear(); + window.location.href = 'https://www.armarium.ch'; + }, + error: () => this.deleteError.set('settings.delete_wrong_password'), + }); + } +} diff --git a/frontend/src/app/transactions/transaction-list/transaction-list.css b/frontend/src/app/transactions/transaction-list/transaction-list.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/transactions/transaction-list/transaction-list.html b/frontend/src/app/transactions/transaction-list/transaction-list.html new file mode 100644 index 0000000..d00f179 --- /dev/null +++ b/frontend/src/app/transactions/transaction-list/transaction-list.html @@ -0,0 +1,253 @@ + +
+
+

{{ 'transactions.title' | translate }}

+
+ +
+ + +
+
+ + + + + + + + + + + + + @for (transaction of transactions(); track transaction.id) { + + + + + + + + + } @empty { + + + + } + +
{{ 'transactions.col_date' | translate }}{{ 'transactions.col_description' | translate }}{{ 'transactions.col_amount' | translate }}Actions
{{ transaction.date | date:'dd.MM.yyyy' }}{{ transaction.description }} + {{ transaction.amount | number:'1.2-2' }} CHF + +
+ + +
+
+ {{ 'transactions.no_transactions' | translate }} +
+
+
+ + + +@if (showCreateModal()) { +
+
+
+
+ + +
+

{{ 'transactions.create_title' | translate }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showEditModal()) { +
+
+
+
+ + +
+

{{ 'transactions.edit_title' | translate }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+} + + + +@if (showDeleteModal()) { +
+
+
+
+ +
+ + + + +
+ +

{{ 'common.delete_confirm_title' | translate }}

+

{{ 'common.delete_confirm_text' | translate }}

+ +
+ + +
+ +
+
+
+} diff --git a/frontend/src/app/transactions/transaction-list/transaction-list.spec.ts b/frontend/src/app/transactions/transaction-list/transaction-list.spec.ts new file mode 100644 index 0000000..f53127a --- /dev/null +++ b/frontend/src/app/transactions/transaction-list/transaction-list.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TransactionList } from './transaction-list'; + +describe('TransactionList', () => { + let component: TransactionList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TransactionList], + }).compileComponents(); + + fixture = TestBed.createComponent(TransactionList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/transactions/transaction-list/transaction-list.ts b/frontend/src/app/transactions/transaction-list/transaction-list.ts new file mode 100644 index 0000000..82381e6 --- /dev/null +++ b/frontend/src/app/transactions/transaction-list/transaction-list.ts @@ -0,0 +1,148 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { ApiService } from '../../services/api'; + +@Component({ + selector: 'app-transaction-list', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './transaction-list.html', + styleUrl: './transaction-list.css', +}) +export class TransactionList implements OnInit { + transactions = signal([]); + accounts = signal([]); + + // Create Modal + showCreateModal = signal(false); + newDescription = ''; + newAmount = 0; + newDate = ''; + newSourceAccount = ''; + newDestinationAccount = ''; + + // Edit Modal + showEditModal = signal(false); + editId = 0; + + // Delete Modal + showDeleteModal = signal(false); + deleteTargetId = 0; + editDescription = ''; + editAmount = 0; + editDate = ''; + editSourceAccount = ''; + editDestinationAccount = ''; + + constructor(private api: ApiService) {} + + ngOnInit(): void { + this.loadTransactions(); + this.loadAccounts(); + } + + loadTransactions() { + this.api.getTransactions().subscribe({ + next: (data) => this.transactions.set(data), + error: (err) => console.error('Fehler:', err) + }); + } + + loadAccounts() { + this.api.getAccounts().subscribe({ + next: (data) => this.accounts.set(data), + error: (err) => console.error('Fehler:', err) + }); + } + + // Create + openCreateModal() { + this.showCreateModal.set(true); + } + + closeCreateModal() { + this.showCreateModal.set(false); + this.newDescription = ''; + this.newAmount = 0; + this.newDate = ''; + this.newSourceAccount = ''; + this.newDestinationAccount = ''; + } + + createTransaction() { + if (!this.newDescription || !this.newDate) return; + this.api.createTransaction({ + description: this.newDescription, + amount: this.newAmount, + date: this.newDate, + source_account: this.newSourceAccount, + destination_account: this.newDestinationAccount + }).subscribe({ + next: () => { + this.loadTransactions(); + this.closeCreateModal(); + }, + error: (err) => console.error('Fehler beim Erstellen:', err) + }); + } + + // Edit + openEditModal(transaction: any) { + this.editId = transaction.id; + this.editDescription = transaction.description; + this.editAmount = transaction.amount; + this.editDate = transaction.date; + this.editSourceAccount = transaction.source_account; + this.editDestinationAccount = transaction.destination_account; + this.showEditModal.set(true); + } + + closeEditModal() { + this.showEditModal.set(false); + } + + updateTransaction() { + if (!this.editDescription || !this.editDate) return; + this.api.updateTransaction(this.editId, { + description: this.editDescription, + amount: this.editAmount, + date: this.editDate, + source_account: this.editSourceAccount, + destination_account: this.editDestinationAccount + }).subscribe({ + next: () => { + this.loadTransactions(); + this.closeEditModal(); + }, + error: (err) => console.error('Fehler beim Bearbeiten:', err) + }); + } + + // Delete + openDeleteModal(id: number) { + this.deleteTargetId = id; + this.showDeleteModal.set(true); + } + + closeDeleteModal() { + this.showDeleteModal.set(false); + this.deleteTargetId = 0; + } + + confirmDelete() { + this.api.deleteTransaction(this.deleteTargetId).subscribe({ + next: () => { + this.loadTransactions(); + this.closeDeleteModal(); + }, + error: (err) => console.error('Error deleting transaction:', err) + }); + } + + accountName(id: any): string { + const acc = this.accounts().find(a => a.id == id); + return acc ? acc.name : (id ?? '—'); + } +} \ No newline at end of file diff --git a/frontend/src/assets/Icon.svg b/frontend/src/assets/Icon.svg new file mode 100644 index 0000000..6601b86 --- /dev/null +++ b/frontend/src/assets/Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logo_horizontal.svg b/frontend/src/assets/Logo_horizontal.svg new file mode 100644 index 0000000..8f7ee7c --- /dev/null +++ b/frontend/src/assets/Logo_horizontal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/Logo_vertikal.svg b/frontend/src/assets/Logo_vertikal.svg new file mode 100644 index 0000000..145f9e7 --- /dev/null +++ b/frontend/src/assets/Logo_vertikal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json new file mode 100644 index 0000000..e574d6f --- /dev/null +++ b/frontend/src/assets/i18n/de.json @@ -0,0 +1,404 @@ +{ + "common": { + "save_changes": "Änderungen speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "create": "Erstellen", + "add": "Hinzufügen", + "close": "Schliessen", + "save": "Speichern", + "name": "Name", + "optional": "optional", + "delete_confirm_title": "Eintrag löschen?", + "delete_confirm_text": "Diese Aktion kann nicht rückgängig gemacht werden.", + "no_accounts_title": "Kein Konto vorhanden", + "no_accounts_text": "Um einen Eintrag zu erstellen, muss zuerst ein Konto angelegt werden.", + "go_to_accounts": "Zu den Konten" + }, + "auth": { + "sign_in": "Anmelden", + "sign_up": "Registrieren", + "signing_in": "Anmeldung läuft...", + "creating_account": "Konto wird erstellt...", + "username": "Benutzername", + "email": "E-Mail", + "password": "Passwort", + "confirm_password": "Passwort bestätigen", + "tagline_login": "Bei deinem Konto anmelden", + "tagline_register": "Konto erstellen", + "no_account": "Noch kein Konto?", + "create_account": "Konto erstellen", + "has_account": "Bereits ein Konto?", + "password_hint": "Mindestens 8 Zeichen.", + "username_hint": "Wird in der App als Anzeigename verwendet.", + "totp_title": "2-Faktor-Authentifizierung", + "totp_hint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "totp_or_backup": "Oder gib einen Backup-Code ein.", + "totp_no_device": "Ich habe mein 2FA-Gerät nicht", + "totp_use_backup": "Sicherungs-Wiederherstellungscode verwenden", + "totp_no_backup": "Ich habe auch keinen Wiederherstellungscode", + "backup_format_hint": "Format: XXXXXXXX-XXXXXXXX", + "recovery_title": "Konto-Wiederherstellung", + "recovery_intro": "Um dein Konto sicher zu halten, möchten wir sicherstellen, dass du es wirklich bist. Ein Bestätigungscode wird an deine hinterlegte Wiederherstellungs-E-Mail gesendet.", + "recovery_send": "Bestätigungscode senden", + "recovery_sent": "Eine E-Mail mit einem Bestätigungscode wurde gerade gesendet an:", + "recovery_spam_hint": "Wenn du die Nachricht nicht in deinem Posteingang findest, überprüfe bitte deinen Spam-Ordner.", + "recovery_confirm": "Code bestätigen", + "recovery_verifying": "Link wird überprüft…", + "recovery_success": "2FA deaktiviert. Du wirst weitergeleitet…", + "recovery_redirecting": "Weiterleitung zum Dashboard…", + "recovery_error": "Der Link ist ungültig oder abgelaufen.", + "keep_signed_in": "Angemeldet bleiben", + "keep_signed_in_hint": "Auf vertrauenswürdigen Geräten empfohlen.", + "back_to_login": "Zurück zur Anmeldung", + "errors": { + "fields_required": "Alle Felder sind erforderlich.", + "passwords_mismatch": "Passwörter stimmen nicht überein.", + "password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.", + "invalid_credentials": "Ungültiger Benutzername oder Passwort.", + "enter_credentials": "Bitte Benutzername und Passwort eingeben.", + "registration_failed": "Registrierung fehlgeschlagen.", + "invalid_totp": "Ungültiger oder abgelaufener Code.", + "captcha_failed": "Sicherheitsüberprüfung fehlgeschlagen. Bitte versuche es erneut.", + "reset_failed": "Ungültiger oder abgelaufener Link.", + "token_missing": "Kein Token gefunden.", + "verify_failed": "Ungültiger oder abgelaufener Bestätigungslink." + }, + "forgot_password": "Passwort vergessen?", + "forgot_password_tagline": "Passwort zurücksetzen", + "forgot_password_hint": "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, senden wir dir einen Reset-Link.", + "send_reset_link": "Reset-Link senden", + "sending": "Wird gesendet...", + "reset_link_sent": "Link gesendet", + "reset_link_sent_hint": "Falls ein Konto mit dieser Adresse existiert, hast du eine E-Mail erhalten. Überprüfe auch deinen Spam-Ordner.", + "reset_password": "Passwort zurücksetzen", + "reset_password_tagline": "Neues Passwort festlegen", + "reset_password_hint": "Gib dein neues Passwort ein.", + "new_password": "Neues Passwort", + "resetting": "Wird gesetzt...", + "reset_success": "Passwort erfolgreich geändert.", + "verifying": "E-Mail-Adresse wird bestätigt...", + "email_verified": "E-Mail-Adresse bestätigt.", + "verify_email_error": "Bestätigung fehlgeschlagen" + }, + "settings": { + "subtitle": "Sicherheit und Account-Einstellungen", + "recovery_email": "Wiederherstellungs-E-Mail", + "recovery_email_hint": "Diese E-Mail wird für die 2FA-Wiederherstellung verwendet. Verwende eine andere Adresse als deine Login-E-Mail.", + "recovery_email_saved": "E-Mail gespeichert.", + "sessions_title": "Aktive Sitzungen", + "sessions_hint": "Alle Geräte, auf denen du aktuell angemeldet bist.", + "sessions_current": "Aktuelle Sitzung", + "sessions_revoke": "Abmelden", + "sessions_revoke_all": "Alle anderen abmelden", + "sessions_loading": "Sitzungen werden geladen…", + "export_title": "Daten exportieren", + "export_hint": "Lade einen ZIP-Ordner mit allen deinen Daten als PDF herunter.", + "export_btn": "Export herunterladen", + "export_loading": "Export wird erstellt…", + "notif_title": "Benachrichtigungen", + "notif_hint": "Wähle, worüber du informiert werden möchtest.", + "notif_deadlines": "Anstehende Termine", + "notif_deadlines_hint": "Erinnerung bei bevorstehenden Terminen.", + "notif_budget_alerts": "Budget-Warnungen", + "notif_budget_alerts_hint": "Benachrichtigung wenn ein Budget überschritten wird.", + "notif_monthly_summary": "Monatliche Zusammenfassung", + "notif_monthly_summary_hint": "Übersicht über Einnahmen und Ausgaben am Monatsende.", + "notif_saved": "Einstellungen gespeichert.", + "delete_step1_title": "Daten zuerst exportieren", + "delete_step1_hint": "Lade deine Daten herunter, bevor du das Konto löschst. Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_step1_btn": "Daten exportieren", + "delete_step2_exported": "Export heruntergeladen", + "delete_password_label": "Passwort bestätigen", + "delete_phrase_label": "Tippe zur Bestätigung:", + "delete_wrong_password": "Passwort ist falsch.", + "sessions_unknown_device": "Unbekanntes Gerät" + }, + "nav": { + "search_placeholder": "Suchen...", + "no_results": "Keine Ergebnisse gefunden.", + "notifications": "Benachrichtigungen", + "no_notifications": "Keine neuen Benachrichtigungen.", + "mark_read": "Als gelesen markieren", + "mark_all_read": "Alle als gelesen markieren", + "more_coming_soon": "Weitere Funktionen folgen", + "sign_out": "Abmelden", + "profile": "Profil", + "settings": "Einstellungen", + "dark_mode": "Dunkelmodus", + "light_mode": "Hellmodus" + }, + "search": { + "accounts": "Konten", + "budgets": "Budgets", + "expenses": "Ausgaben", + "transactions": "Transaktionen", + "deadlines": "Termine" + }, + "sidebar": { + "dashboard": "Dashboard", + "budgets": "Budgets", + "fixed_costs": "Fixkosten", + "expenses": "Ausgaben", + "calendar": "Kalender", + "accounts": "Konten", + "revenue_accounts": "Einnahmekonten", + "transactions": "Transaktionen" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Finanzübersicht", + "total_income": "Gesamteinnahmen", + "fixed_costs": "Fixkosten", + "expenses": "Ausgaben", + "balance": "Saldo", + "per_month": "CHF / Monat", + "chf_total": "CHF gesamt", + "chf_remaining": "CHF verbleibend", + "income_vs_expenses": "Einnahmen vs. Ausgaben {{ year }}", + "fixed_costs_breakdown": "Fixkostenaufschlüsselung", + "savings_rate": "Sparquote", + "of_income": "des Einkommens", + "goal": "Sparziel", + "goal_hint": "Empfohlen: mind. 20% des Einkommens sparen.", + "recent_expenses": "Letzte Ausgaben", + "no_expenses": "Noch keine Ausgaben erfasst.", + "series_income": "Einnahmen", + "series_fixed_costs": "Fixkosten", + "series_expenses": "Variable Ausgaben", + "view_report": "Bericht anzeigen", + "greeting_morning": "Guten Morgen", + "greeting_afternoon": "Guten Tag", + "greeting_evening": "Guten Abend", + "greeting_night": "Gute Nacht" + }, + "accounts": { + "title": "Alle Konten", + "add": "Konto hinzufügen", + "col_type": "Typ", + "col_balance": "Kontostand (CHF)", + "no_accounts": "Noch keine Konten vorhanden.", + "create_title": "Neues Konto erstellen", + "edit_title": "Konto bearbeiten", + "label_balance": "Kontostand (CHF)", + "label_type": "Kontotyp", + "type_asset": "Vermögen", + "type_revenue": "Einnahmequelle", + "placeholder_name": "z.B. Sparkonto" + }, + "budgets": { + "title": "Fixkosten & Budget", + "subtitle": "Gesamtausgaben: ", + "add": "Hinzufügen", + "new_entry": "Neuer Eintrag — {{ category }}", + "edit_entry": "Eintrag bearbeiten", + "label_amount": "Betrag (CHF)", + "label_category": "Kategorie", + "label_account": "Konto", + "label_active": "Aktiv", + "label_suggestions": "Vorschläge", + "no_entries": "Noch keine Einträge in dieser Kategorie.", + "entries_count": "({{ count }} Einträge)", + "placeholder_name": "z.B. Miete", + "categories": { + "fixed_expenses": "Fixe Ausgaben", + "mobile_internet": "Mobile & Internet", + "subscriptions": "Abonnements", + "leisure": "Freizeit", + "tax_reserves": "Steuerrücklagen", + "insurance": "Versicherungen", + "loans": "Abzahlungen & Kredite" + } + }, + "expenses": { + "title": "Ausgaben", + "total": "Total:", + "add": "Ausgabe hinzufügen", + "col_date": "Datum", + "col_name": "Name", + "col_category": "Kategorie", + "col_account": "Konto", + "col_amount": "Betrag", + "no_expenses": "Noch keine Ausgaben erfasst.", + "create_title": "Ausgabe hinzufügen", + "edit_title": "Ausgabe bearbeiten", + "label_amount": "Betrag (CHF)", + "label_date": "Datum", + "label_category": "Kategorie", + "label_account": "Konto", + "label_due_date": "Fälligkeitsdatum", + "label_notes": "Notizen", + "placeholder_name": "z.B. Migros", + "categories": { + "groceries": "Lebensmittel", + "dining": "Restaurant & Essen", + "transport": "Transport", + "health": "Gesundheit & Medizin", + "clothing": "Kleidung", + "electronics": "Elektronik", + "household": "Haushalt", + "entertainment": "Unterhaltung", + "travel": "Reisen", + "other": "Sonstiges" + } + }, + "transactions": { + "title": "Alle Transaktionen", + "add": "Transaktion hinzufügen", + "col_date": "Datum", + "col_description": "Beschreibung", + "col_from": "Von", + "col_to": "Nach", + "col_amount": "Betrag", + "no_transactions": "Noch keine Transaktionen vorhanden.", + "create_title": "Neue Transaktion erstellen", + "edit_title": "Transaktion bearbeiten", + "label_description": "Beschreibung", + "label_amount": "Betrag (CHF)", + "label_date": "Datum", + "label_from": "Von Konto", + "label_to": "Nach Konto", + "select_account": "Konto wählen...", + "placeholder_description": "z.B. Miete Januar" + }, + "calendar": { + "year_view": "Jahresansicht", + "subscribe": "Abonnieren", + "ical_title": "Deine iCal-Feed-URL", + "ical_desc": "Füge diese URL in jede Kalender-App ein (Protonmail, Google, Apple, Outlook).", + "ical_copy": "Kopieren", + "ical_copied": "Kopiert!", + "filter_holidays": "Feiertage", + "filter_school": "Schulferien", + "filter_invoices": "Rechnungen", + "filter_deadlines": "Termine", + "add_deadline": "Termin hinzufügen", + "select_type": "Grund wählen...", + "label_title": "Titel", + "label_date": "Datum", + "label_type": "Typ", + "label_notes": "Notizen", + "deadline_types": { + "tax": "Steuer", + "insurance": "Versicherung", + "invoice": "Rechnung", + "personal": "Persönlich", + "other": "Sonstiges" + }, + "months": { + "1": "Januar", + "2": "Februar", + "3": "März", + "4": "April", + "5": "Mai", + "6": "Juni", + "7": "Juli", + "8": "August", + "9": "September", + "10": "Oktober", + "11": "November", + "12": "Dezember" + }, + "weekdays": [ + "Mo", + "Di", + "Mi", + "Do", + "Fr", + "Sa", + "So" + ], + "no_events": "Keine Ereignisse an diesem Tag.", + "more_events": "+{{ count }} weitere", + "legend_national": "Nationaler Feiertag", + "legend_cantonal": "Kantonaler Feiertag", + "legend_school": "Schulferien", + "legend_expense": "Zahlungstermin", + "legend_personal": "Persönlicher Termin" + }, + "canton_names": { + "AG": "Aargau", + "AI": "Appenzell Innerrhoden", + "AR": "Appenzell Ausserrhoden", + "BE": "Bern", + "BL": "Basel-Landschaft", + "BS": "Basel-Stadt", + "FR": "Freiburg", + "GE": "Genf", + "GL": "Glarus", + "GR": "Graubünden", + "JU": "Jura", + "LU": "Luzern", + "NE": "Neuenburg", + "NW": "Nidwalden", + "OW": "Obwalden", + "SG": "St. Gallen", + "SH": "Schaffhausen", + "SO": "Solothurn", + "SZ": "Schwyz", + "TG": "Thurgau", + "TI": "Tessin", + "UR": "Uri", + "VD": "Waadt", + "VS": "Wallis", + "ZG": "Zug", + "ZH": "Zürich" + }, + "profile": { + "title": "Profil", + "subtitle": "Persönliche Informationen und Einstellungen verwalten", + "personal_info": "Persönliche Informationen", + "profile_photo": "Profilfoto", + "photo_hint": "Klicke auf den Avatar, um ein Foto hochzuladen", + "fallback_color": "Ersatzfarbe", + "first_name": "Vorname", + "last_name": "Nachname", + "email": "E-Mail", + "canton": "Kanton", + "language": "Sprache", + "save_changes": "Änderungen speichern", + "save_success": "Profil erfolgreich gespeichert.", + "change_password": "Passwort ändern", + "new_password": "Neues Passwort", + "confirm_password": "Passwort bestätigen", + "update_password": "Passwort aktualisieren", + "password_success": "Passwort erfolgreich aktualisiert.", + "totp_title": "Zwei-Faktor-Authentifizierung", + "totp_subtitle": "Schütze deinen Account mit einer Authenticator-App. Empfohlen: Proton Pass, Aegis (Android) oder Raivo OTP (iOS).", + "totp_on": "Aktiviert", + "totp_off": "Deaktiviert", + "totp_enable": "2FA aktivieren", + "totp_disable": "2FA deaktivieren", + "totp_scan_hint": "Scanne den QR-Code mit deiner Authenticator-App und gib dann den 6-stelligen Code ein.", + "totp_disable_hint": "Gib den aktuellen Code aus deiner Authenticator-App ein, um 2FA zu deaktivieren.", + "totp_code_label": "Bestätigungscode", + "totp_confirm": "Bestätigen & aktivieren", + "totp_enabled_success": "2FA erfolgreich aktiviert.", + "totp_disabled_success": "2FA wurde deaktiviert.", + "totp_invalid_code": "Ungültiger Code. Bitte versuche es erneut.", + "backup_codes_title": "Backup-Codes speichern", + "backup_codes_hint": "Diese Codes werden nur einmal angezeigt. Sichere sie jetzt.", + "backup_copy": "Kopieren", + "backup_copied": "Kopiert!", + "backup_download_pdf": "Als PDF herunterladen", + "backup_saved": "Ich habe sie gesichert", + "danger_zone": "Gefahrenzone", + "danger_text": "Dein Konto und alle zugehörigen Daten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_account": "Konto löschen", + "delete_account_confirm": "Konto löschen?", + "delete_account_text": "Alle deine Daten werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", + "languages": { + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "en": "English" + }, + "errors": { + "password_empty": "Passwort darf nicht leer sein.", + "passwords_mismatch": "Passwörter stimmen nicht überein.", + "password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.", + "password_failed": "Passwort konnte nicht aktualisiert werden." + } + } +} diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json new file mode 100644 index 0000000..e844ffa --- /dev/null +++ b/frontend/src/assets/i18n/en.json @@ -0,0 +1,404 @@ +{ + "common": { + "save_changes": "Save Changes", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "add": "Add", + "close": "Close", + "save": "Save", + "name": "Name", + "optional": "optional", + "delete_confirm_title": "Delete entry?", + "delete_confirm_text": "This action cannot be undone.", + "no_accounts_title": "No Account Found", + "no_accounts_text": "To create an entry, you need to set up an account first.", + "go_to_accounts": "Go to Accounts" + }, + "auth": { + "sign_in": "Sign In", + "sign_up": "Sign Up", + "signing_in": "Signing in...", + "creating_account": "Creating account...", + "username": "Username", + "email": "Email", + "password": "Password", + "confirm_password": "Confirm Password", + "tagline_login": "Sign in to your account", + "tagline_register": "Create your account", + "no_account": "Don't have an account?", + "create_account": "Create an account", + "has_account": "Already have an account?", + "password_hint": "At least 8 characters.", + "username_hint": "Used as your display name within the app.", + "totp_title": "Two-Factor Authentication", + "totp_hint": "Enter the 6-digit code from your authenticator app.", + "totp_or_backup": "Or enter a backup code.", + "totp_no_device": "I don't have my 2FA device", + "totp_use_backup": "Use security recovery code", + "totp_no_backup": "I don't have a recovery code either", + "backup_format_hint": "Format: XXXXXXXX-XXXXXXXX", + "recovery_title": "Account Recovery", + "recovery_intro": "To keep your account secure, we want to make sure it's really you. A confirmation code will be sent to your registered recovery email.", + "recovery_send": "Send confirmation code", + "recovery_sent": "An email with a confirmation code has just been sent to:", + "recovery_spam_hint": "If you don't find the message in your inbox, please check your spam folder.", + "recovery_confirm": "Confirm code", + "recovery_verifying": "Verifying link…", + "recovery_success": "2FA disabled. Redirecting…", + "recovery_redirecting": "Redirecting to dashboard…", + "recovery_error": "The link is invalid or has expired.", + "keep_signed_in": "Keep me signed in", + "keep_signed_in_hint": "Recommended on trusted devices.", + "back_to_login": "Back to login", + "errors": { + "fields_required": "All fields are required.", + "passwords_mismatch": "Passwords do not match.", + "password_too_short": "Password must be at least 8 characters.", + "invalid_credentials": "Invalid username or password.", + "enter_credentials": "Please enter username and password.", + "registration_failed": "Registration failed.", + "invalid_totp": "Invalid or expired code.", + "captcha_failed": "Security check failed. Please try again.", + "reset_failed": "Invalid or expired link.", + "token_missing": "No token found.", + "verify_failed": "Invalid or expired verification link." + }, + "forgot_password": "Forgot password?", + "forgot_password_tagline": "Reset password", + "forgot_password_hint": "Enter your email address. If an account exists, we'll send you a reset link.", + "send_reset_link": "Send reset link", + "sending": "Sending...", + "reset_link_sent": "Link sent", + "reset_link_sent_hint": "If an account with this address exists, you'll receive an email. Check your spam folder too.", + "reset_password": "Reset password", + "reset_password_tagline": "Set new password", + "reset_password_hint": "Enter your new password.", + "new_password": "New password", + "resetting": "Resetting...", + "reset_success": "Password changed successfully.", + "verifying": "Verifying your email address...", + "email_verified": "Email address verified.", + "verify_email_error": "Verification failed" + }, + "settings": { + "subtitle": "Security and account settings", + "recovery_email": "Recovery Email", + "recovery_email_hint": "This email is used for 2FA recovery. Use a different address than your login email.", + "recovery_email_saved": "Email saved.", + "sessions_title": "Active Sessions", + "sessions_hint": "All devices where you are currently signed in.", + "sessions_current": "Current session", + "sessions_revoke": "Sign out", + "sessions_revoke_all": "Sign out all others", + "sessions_loading": "Loading sessions…", + "export_title": "Export Data", + "export_hint": "Download a ZIP folder with all your data as PDF files.", + "export_btn": "Download Export", + "export_loading": "Preparing export…", + "notif_title": "Notifications", + "notif_hint": "Choose what you'd like to be notified about.", + "notif_deadlines": "Upcoming Deadlines", + "notif_deadlines_hint": "Reminder for upcoming appointments and deadlines.", + "notif_budget_alerts": "Budget Alerts", + "notif_budget_alerts_hint": "Notification when a budget limit is exceeded.", + "notif_monthly_summary": "Monthly Summary", + "notif_monthly_summary_hint": "Overview of income and expenses at the end of the month.", + "notif_saved": "Settings saved.", + "delete_step1_title": "Export your data first", + "delete_step1_hint": "Download your data before deleting your account. This action cannot be undone.", + "delete_step1_btn": "Export data", + "delete_step2_exported": "Export downloaded", + "delete_password_label": "Confirm your password", + "delete_phrase_label": "Type to confirm:", + "delete_wrong_password": "Password is incorrect.", + "sessions_unknown_device": "Unknown Device" + }, + "nav": { + "search_placeholder": "Search...", + "no_results": "No results found.", + "notifications": "Notifications", + "no_notifications": "No new notifications.", + "mark_read": "Mark as read", + "mark_all_read": "Mark all as read", + "more_coming_soon": "More features coming soon", + "sign_out": "Sign out", + "profile": "Profile", + "settings": "Settings", + "dark_mode": "Dark Mode", + "light_mode": "Light Mode" + }, + "search": { + "accounts": "Accounts", + "budgets": "Budgets", + "expenses": "Expenses", + "transactions": "Transactions", + "deadlines": "Deadlines" + }, + "sidebar": { + "dashboard": "Dashboard", + "budgets": "Budgets", + "fixed_costs": "Fixed Costs", + "expenses": "Expenses", + "calendar": "Calendar", + "accounts": "Accounts", + "revenue_accounts": "Revenue Accounts", + "transactions": "Transactions" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Financial overview", + "total_income": "Total Income", + "fixed_costs": "Fixed Costs", + "expenses": "Expenses", + "balance": "Balance", + "per_month": "CHF / month", + "chf_total": "CHF total", + "chf_remaining": "CHF remaining", + "income_vs_expenses": "Income vs. Expenses {{ year }}", + "fixed_costs_breakdown": "Fixed Costs Breakdown", + "savings_rate": "Savings Rate", + "of_income": "of income", + "goal": "Savings Goal", + "goal_hint": "Recommended: save at least 20% of your income.", + "recent_expenses": "Recent Expenses", + "no_expenses": "No expenses recorded yet.", + "series_income": "Income", + "series_fixed_costs": "Fixed Costs", + "series_expenses": "Variable Expenses", + "view_report": "View Report", + "greeting_morning": "Good Morning", + "greeting_afternoon": "Good Afternoon", + "greeting_evening": "Good Evening", + "greeting_night": "Good Night" + }, + "accounts": { + "title": "All Accounts", + "add": "Add Account", + "col_type": "Type", + "col_balance": "Balance (CHF)", + "no_accounts": "No accounts yet.", + "create_title": "Create Account", + "edit_title": "Edit Account", + "label_balance": "Balance (CHF)", + "label_type": "Account Type", + "type_asset": "Asset", + "type_revenue": "Revenue", + "placeholder_name": "e.g. Savings account" + }, + "budgets": { + "title": "Fixed Costs & Budget", + "subtitle": "Total expenses: ", + "add": "Add", + "new_entry": "New Entry — {{ category }}", + "edit_entry": "Edit Entry", + "label_amount": "Amount (CHF)", + "label_category": "Category", + "label_account": "Account", + "label_active": "Active", + "label_suggestions": "Suggestions", + "no_entries": "No entries in this category yet.", + "entries_count": "({{ count }} entries)", + "placeholder_name": "e.g. Rent", + "categories": { + "fixed_expenses": "Fixed Expenses", + "mobile_internet": "Mobile & Internet", + "subscriptions": "Subscriptions", + "leisure": "Leisure", + "tax_reserves": "Tax Reserves", + "insurance": "Insurance", + "loans": "Loans & Credits" + } + }, + "expenses": { + "title": "Expenses", + "total": "Total:", + "add": "Add Expense", + "col_date": "Date", + "col_name": "Name", + "col_category": "Category", + "col_account": "Account", + "col_amount": "Amount", + "no_expenses": "No expenses recorded yet.", + "create_title": "Add Expense", + "edit_title": "Edit Expense", + "label_amount": "Amount (CHF)", + "label_date": "Date", + "label_category": "Category", + "label_account": "Account", + "label_due_date": "Payment Due Date", + "label_notes": "Notes", + "placeholder_name": "e.g. Migros", + "categories": { + "groceries": "Groceries", + "dining": "Dining & Restaurants", + "transport": "Transport", + "health": "Health & Medical", + "clothing": "Clothing", + "electronics": "Electronics", + "household": "Household", + "entertainment": "Entertainment", + "travel": "Travel", + "other": "Other" + } + }, + "transactions": { + "title": "All Transactions", + "add": "Add Transaction", + "col_date": "Date", + "col_description": "Description", + "col_from": "From", + "col_to": "To", + "col_amount": "Amount", + "no_transactions": "No transactions yet.", + "create_title": "Create Transaction", + "edit_title": "Edit Transaction", + "label_description": "Description", + "label_amount": "Amount (CHF)", + "label_date": "Date", + "label_from": "From Account", + "label_to": "To Account", + "select_account": "Select account...", + "placeholder_description": "e.g. January Rent" + }, + "calendar": { + "year_view": "Year view", + "subscribe": "Subscribe", + "ical_title": "Your iCal Feed URL", + "ical_desc": "Add this URL to any calendar app (Protonmail, Google, Apple, Outlook).", + "ical_copy": "Copy", + "ical_copied": "Copied!", + "filter_holidays": "Holidays", + "filter_school": "School Holidays", + "filter_invoices": "Invoices", + "filter_deadlines": "Deadlines", + "add_deadline": "Add Deadline", + "select_type": "Select reason...", + "label_title": "Title", + "label_date": "Date", + "label_type": "Type", + "label_notes": "Notes", + "deadline_types": { + "tax": "Tax", + "insurance": "Insurance", + "invoice": "Invoice", + "personal": "Personal", + "other": "Other" + }, + "months": { + "1": "January", + "2": "February", + "3": "March", + "4": "April", + "5": "May", + "6": "June", + "7": "July", + "8": "August", + "9": "September", + "10": "October", + "11": "November", + "12": "December" + }, + "weekdays": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ], + "no_events": "No events on this day.", + "more_events": "+{{ count }} more", + "legend_national": "National Holiday", + "legend_cantonal": "Cantonal Holiday", + "legend_school": "School Holiday", + "legend_expense": "Expense Due Date", + "legend_personal": "Personal Deadline" + }, + "canton_names": { + "AG": "Aargau", + "AI": "Appenzell Inner Rhodes", + "AR": "Appenzell Outer Rhodes", + "BE": "Bern", + "BL": "Basel-Landschaft", + "BS": "Basel-Stadt", + "FR": "Fribourg", + "GE": "Geneva", + "GL": "Glarus", + "GR": "Graubünden", + "JU": "Jura", + "LU": "Lucerne", + "NE": "Neuchâtel", + "NW": "Nidwalden", + "OW": "Obwalden", + "SG": "St. Gallen", + "SH": "Schaffhausen", + "SO": "Solothurn", + "SZ": "Schwyz", + "TG": "Thurgau", + "TI": "Ticino", + "UR": "Uri", + "VD": "Vaud", + "VS": "Valais", + "ZG": "Zug", + "ZH": "Zuerich" + }, + "profile": { + "title": "Profile", + "subtitle": "Manage your personal information and settings", + "personal_info": "Personal Information", + "profile_photo": "Profile Photo", + "photo_hint": "Click on the avatar to upload a photo", + "fallback_color": "Fallback Color", + "first_name": "First Name", + "last_name": "Last Name", + "email": "Email", + "canton": "Canton", + "language": "Language", + "save_changes": "Save Changes", + "save_success": "Profile saved successfully.", + "change_password": "Change Password", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "update_password": "Update Password", + "password_success": "Password updated successfully.", + "totp_title": "Two-Factor Authentication", + "totp_subtitle": "Protect your account with an authenticator app. Recommended: Proton Pass, Aegis (Android) or Raivo OTP (iOS).", + "totp_on": "Enabled", + "totp_off": "Disabled", + "totp_enable": "Enable 2FA", + "totp_disable": "Disable 2FA", + "totp_scan_hint": "Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.", + "totp_disable_hint": "Enter the current code from your authenticator app to disable 2FA.", + "totp_code_label": "Confirmation code", + "totp_confirm": "Confirm & enable", + "totp_enabled_success": "2FA successfully enabled.", + "totp_disabled_success": "2FA has been disabled.", + "totp_invalid_code": "Invalid code. Please try again.", + "backup_codes_title": "Save your backup codes", + "backup_codes_hint": "These codes are shown only once. Save them somewhere safe.", + "backup_copy": "Copy", + "backup_copied": "Copied!", + "backup_download_pdf": "Download as PDF", + "backup_saved": "I've saved them", + "danger_zone": "Danger Zone", + "danger_text": "Permanently delete your account and all associated data. This action cannot be undone.", + "delete_account": "Delete Account", + "delete_account_confirm": "Delete Account?", + "delete_account_text": "All your data will be permanently deleted. This cannot be undone.", + "languages": { + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "en": "English" + }, + "errors": { + "password_empty": "Password cannot be empty.", + "passwords_mismatch": "Passwords do not match.", + "password_too_short": "Password must be at least 8 characters.", + "password_failed": "Failed to update password." + } + } +} diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json new file mode 100644 index 0000000..949a23f --- /dev/null +++ b/frontend/src/assets/i18n/fr.json @@ -0,0 +1,404 @@ +{ + "common": { + "save_changes": "Enregistrer les modifications", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "add": "Ajouter", + "close": "Fermer", + "save": "Enregistrer", + "name": "Nom", + "optional": "optionnel", + "delete_confirm_title": "Supprimer l'entrée ?", + "delete_confirm_text": "Cette action est irréversible.", + "no_accounts_title": "Aucun compte disponible", + "no_accounts_text": "Pour créer une entrée, vous devez d'abord configurer un compte.", + "go_to_accounts": "Aller aux comptes" + }, + "auth": { + "sign_in": "Se connecter", + "sign_up": "S'inscrire", + "signing_in": "Connexion en cours...", + "creating_account": "Création du compte...", + "username": "Nom d'utilisateur", + "email": "E-mail", + "password": "Mot de passe", + "confirm_password": "Confirmer le mot de passe", + "tagline_login": "Connectez-vous à votre compte", + "tagline_register": "Créez votre compte", + "no_account": "Pas encore de compte ?", + "create_account": "Créer un compte", + "has_account": "Déjà un compte ?", + "password_hint": "Au moins 8 caractères.", + "username_hint": "Utilisé comme nom d'affichage dans l'application.", + "totp_title": "Authentification à deux facteurs", + "totp_hint": "Saisissez le code à 6 chiffres de votre application d'authentification.", + "totp_or_backup": "Ou saisissez un code de secours.", + "totp_no_device": "Je n'ai pas mon appareil 2FA", + "totp_use_backup": "Utiliser un code de récupération", + "totp_no_backup": "Je n'ai pas non plus de code de récupération", + "backup_format_hint": "Format : XXXXXXXX-XXXXXXXX", + "recovery_title": "Récupération du compte", + "recovery_intro": "Pour sécuriser votre compte, nous voulons nous assurer que c'est bien vous. Un code de confirmation sera envoyé à votre adresse e-mail de récupération enregistrée.", + "recovery_send": "Envoyer le code de confirmation", + "recovery_sent": "Un e-mail avec un code de confirmation vient d'être envoyé à :", + "recovery_spam_hint": "Si vous ne trouvez pas le message dans votre boîte de réception, vérifiez votre dossier spam.", + "recovery_confirm": "Confirmer le code", + "recovery_verifying": "Vérification du lien…", + "recovery_success": "2FA désactivé. Redirection en cours…", + "recovery_redirecting": "Redirection vers le tableau de bord…", + "recovery_error": "Le lien est invalide ou a expiré.", + "keep_signed_in": "Rester connecté", + "keep_signed_in_hint": "Recommandé sur les appareils de confiance.", + "back_to_login": "Retour à la connexion", + "errors": { + "fields_required": "Tous les champs sont obligatoires.", + "passwords_mismatch": "Les mots de passe ne correspondent pas.", + "password_too_short": "Le mot de passe doit comporter au moins 8 caractères.", + "invalid_credentials": "Nom d'utilisateur ou mot de passe invalide.", + "enter_credentials": "Veuillez saisir votre nom d'utilisateur et votre mot de passe.", + "registration_failed": "L'inscription a échoué.", + "invalid_totp": "Code invalide ou expiré.", + "captcha_failed": "La vérification de sécurité a échoué. Veuillez réessayer.", + "reset_failed": "Lien invalide ou expiré.", + "token_missing": "Aucun token trouvé.", + "verify_failed": "Lien de vérification invalide ou expiré." + }, + "forgot_password": "Mot de passe oublié ?", + "forgot_password_tagline": "Réinitialiser le mot de passe", + "forgot_password_hint": "Saisissez votre adresse e-mail. Si un compte existe, nous vous enverrons un lien de réinitialisation.", + "send_reset_link": "Envoyer le lien", + "sending": "Envoi en cours...", + "reset_link_sent": "Lien envoyé", + "reset_link_sent_hint": "Si un compte avec cette adresse existe, vous recevrez un e-mail. Vérifiez également vos spams.", + "reset_password": "Réinitialiser le mot de passe", + "reset_password_tagline": "Définir un nouveau mot de passe", + "reset_password_hint": "Saisissez votre nouveau mot de passe.", + "new_password": "Nouveau mot de passe", + "resetting": "Réinitialisation...", + "reset_success": "Mot de passe modifié avec succès.", + "verifying": "Vérification de votre adresse e-mail...", + "email_verified": "Adresse e-mail vérifiée.", + "verify_email_error": "Échec de la vérification" + }, + "settings": { + "subtitle": "Sécurité et paramètres du compte", + "recovery_email": "E-mail de récupération", + "recovery_email_hint": "Cet e-mail est utilisé pour la récupération 2FA. Utilisez une adresse différente de votre e-mail de connexion.", + "recovery_email_saved": "E-mail enregistré.", + "sessions_title": "Sessions actives", + "sessions_hint": "Tous les appareils sur lesquels vous êtes actuellement connecté.", + "sessions_current": "Session actuelle", + "sessions_revoke": "Déconnecter", + "sessions_revoke_all": "Déconnecter toutes les autres", + "sessions_loading": "Chargement des sessions…", + "export_title": "Exporter les données", + "export_hint": "Téléchargez un dossier ZIP avec toutes vos données en PDF.", + "export_btn": "Télécharger l'export", + "export_loading": "Préparation de l'export…", + "notif_title": "Notifications", + "notif_hint": "Choisissez ce dont vous souhaitez être informé.", + "notif_deadlines": "Échéances à venir", + "notif_deadlines_hint": "Rappel pour les rendez-vous et délais à venir.", + "notif_budget_alerts": "Alertes budget", + "notif_budget_alerts_hint": "Notification lorsqu'un budget est dépassé.", + "notif_monthly_summary": "Résumé mensuel", + "notif_monthly_summary_hint": "Aperçu des revenus et dépenses en fin de mois.", + "notif_saved": "Paramètres enregistrés.", + "delete_step1_title": "Exportez d'abord vos données", + "delete_step1_hint": "Téléchargez vos données avant de supprimer votre compte. Cette action est irréversible.", + "delete_step1_btn": "Exporter les données", + "delete_step2_exported": "Export téléchargé", + "delete_password_label": "Confirmez votre mot de passe", + "delete_phrase_label": "Tapez pour confirmer :", + "delete_wrong_password": "Le mot de passe est incorrect.", + "sessions_unknown_device": "Appareil inconnu" + }, + "nav": { + "search_placeholder": "Rechercher...", + "no_results": "Aucun résultat trouvé.", + "notifications": "Notifications", + "no_notifications": "Aucune nouvelle notification.", + "mark_read": "Marquer comme lu", + "mark_all_read": "Tout marquer comme lu", + "more_coming_soon": "D'autres fonctionnalités arrivent bientôt", + "sign_out": "Déconnexion", + "profile": "Profil", + "settings": "Paramètres", + "dark_mode": "Mode sombre", + "light_mode": "Mode clair" + }, + "search": { + "accounts": "Comptes", + "budgets": "Budgets", + "expenses": "Dépenses", + "transactions": "Transactions", + "deadlines": "Échéances" + }, + "sidebar": { + "dashboard": "Tableau de bord", + "budgets": "Budgets", + "fixed_costs": "Charges fixes", + "expenses": "Dépenses", + "calendar": "Calendrier", + "accounts": "Comptes", + "revenue_accounts": "Comptes de revenus", + "transactions": "Transactions" + }, + "dashboard": { + "title": "Tableau de bord", + "subtitle": "Aperçu financier", + "total_income": "Revenus totaux", + "fixed_costs": "Charges fixes", + "expenses": "Dépenses", + "balance": "Solde", + "per_month": "CHF / mois", + "chf_total": "CHF total", + "chf_remaining": "CHF restants", + "income_vs_expenses": "Revenus vs. Dépenses {{ year }}", + "fixed_costs_breakdown": "Répartition des charges fixes", + "savings_rate": "Taux d'épargne", + "of_income": "des revenus", + "goal": "Objectif d'épargne", + "goal_hint": "Recommandé : épargner au moins 20 % de ses revenus.", + "recent_expenses": "Dernières dépenses", + "no_expenses": "Aucune dépense enregistrée.", + "series_income": "Revenus", + "series_fixed_costs": "Charges fixes", + "series_expenses": "Dépenses variables", + "view_report": "Voir le rapport", + "greeting_morning": "Bonjour", + "greeting_afternoon": "Bon après-midi", + "greeting_evening": "Bonsoir", + "greeting_night": "Bonne nuit" + }, + "accounts": { + "title": "Tous les comptes", + "add": "Ajouter un compte", + "col_type": "Type", + "col_balance": "Solde (CHF)", + "no_accounts": "Aucun compte pour l'instant.", + "create_title": "Créer un compte", + "edit_title": "Modifier le compte", + "label_balance": "Solde (CHF)", + "label_type": "Type de compte", + "type_asset": "Actif", + "type_revenue": "Revenus", + "placeholder_name": "ex. Compte épargne" + }, + "budgets": { + "title": "Charges fixes & Budget", + "subtitle": "Total des dépenses : ", + "add": "Ajouter", + "new_entry": "Nouvelle entrée — {{ category }}", + "edit_entry": "Modifier l'entrée", + "label_amount": "Montant (CHF)", + "label_category": "Catégorie", + "label_account": "Compte", + "label_active": "Actif", + "label_suggestions": "Suggestions", + "no_entries": "Aucune entrée dans cette catégorie.", + "entries_count": "({{ count }} entrées)", + "placeholder_name": "ex. Loyer", + "categories": { + "fixed_expenses": "Charges fixes", + "mobile_internet": "Mobile & Internet", + "subscriptions": "Abonnements", + "leisure": "Loisirs", + "tax_reserves": "Réserves fiscales", + "insurance": "Assurances", + "loans": "Emprunts & Crédits" + } + }, + "expenses": { + "title": "Dépenses", + "total": "Total :", + "add": "Ajouter une dépense", + "col_date": "Date", + "col_name": "Nom", + "col_category": "Catégorie", + "col_account": "Compte", + "col_amount": "Montant", + "no_expenses": "Aucune dépense enregistrée.", + "create_title": "Ajouter une dépense", + "edit_title": "Modifier la dépense", + "label_amount": "Montant (CHF)", + "label_date": "Date", + "label_category": "Catégorie", + "label_account": "Compte", + "label_due_date": "Date d'échéance", + "label_notes": "Notes", + "placeholder_name": "ex. Migros", + "categories": { + "groceries": "Alimentation", + "dining": "Restaurants", + "transport": "Transport", + "health": "Santé & Médecine", + "clothing": "Vêtements", + "electronics": "Électronique", + "household": "Ménage", + "entertainment": "Divertissement", + "travel": "Voyages", + "other": "Autre" + } + }, + "transactions": { + "title": "Toutes les transactions", + "add": "Ajouter une transaction", + "col_date": "Date", + "col_description": "Description", + "col_from": "De", + "col_to": "Vers", + "col_amount": "Montant", + "no_transactions": "Aucune transaction pour l'instant.", + "create_title": "Créer une transaction", + "edit_title": "Modifier la transaction", + "label_description": "Description", + "label_amount": "Montant (CHF)", + "label_date": "Date", + "label_from": "Compte source", + "label_to": "Compte destination", + "select_account": "Sélectionner un compte...", + "placeholder_description": "ex. Loyer janvier" + }, + "calendar": { + "year_view": "Vue annuelle", + "subscribe": "S'abonner", + "ical_title": "Votre URL de flux iCal", + "ical_desc": "Ajoutez cette URL à n'importe quelle application calendrier (Protonmail, Google, Apple, Outlook).", + "ical_copy": "Copier", + "ical_copied": "Copié !", + "filter_holidays": "Jours fériés", + "filter_school": "Vacances scolaires", + "filter_invoices": "Factures", + "filter_deadlines": "Échéances", + "add_deadline": "Ajouter une échéance", + "select_type": "Choisir un motif...", + "label_title": "Titre", + "label_date": "Date", + "label_type": "Type", + "label_notes": "Notes", + "deadline_types": { + "tax": "Impôt", + "insurance": "Assurance", + "invoice": "Facture", + "personal": "Personnel", + "other": "Autre" + }, + "months": { + "1": "Janvier", + "2": "Février", + "3": "Mars", + "4": "Avril", + "5": "Mai", + "6": "Juin", + "7": "Juillet", + "8": "Août", + "9": "Septembre", + "10": "Octobre", + "11": "Novembre", + "12": "Décembre" + }, + "weekdays": [ + "Lun", + "Mar", + "Mer", + "Jeu", + "Ven", + "Sam", + "Dim" + ], + "no_events": "Aucun événement ce jour.", + "more_events": "+{{ count }} autres", + "legend_national": "Fête nationale", + "legend_cantonal": "Fête cantonale", + "legend_school": "Vacances scolaires", + "legend_expense": "Date d'échéance", + "legend_personal": "Échéance personnelle" + }, + "canton_names": { + "AG": "Argovie", + "AI": "Appenzell Rhodes-Intérieures", + "AR": "Appenzell Rhodes-Extérieures", + "BE": "Berne", + "BL": "Bâle-Campagne", + "BS": "Bâle-Ville", + "FR": "Fribourg", + "GE": "Genève", + "GL": "Glaris", + "GR": "Grisons", + "JU": "Jura", + "LU": "Lucerne", + "NE": "Neuchâtel", + "NW": "Nidwald", + "OW": "Obwald", + "SG": "Saint-Gall", + "SH": "Schaffhouse", + "SO": "Soleure", + "SZ": "Schwytz", + "TG": "Thurgovie", + "TI": "Tessin", + "UR": "Uri", + "VD": "Vaud", + "VS": "Valais", + "ZG": "Zoug", + "ZH": "Zurich" + }, + "profile": { + "title": "Profil", + "subtitle": "Gérer vos informations personnelles et paramètres", + "personal_info": "Informations personnelles", + "profile_photo": "Photo de profil", + "photo_hint": "Cliquez sur l'avatar pour télécharger une photo", + "fallback_color": "Couleur de repli", + "first_name": "Prénom", + "last_name": "Nom de famille", + "email": "E-mail", + "canton": "Canton", + "language": "Langue", + "save_changes": "Enregistrer les modifications", + "save_success": "Profil enregistré avec succès.", + "change_password": "Changer le mot de passe", + "new_password": "Nouveau mot de passe", + "confirm_password": "Confirmer le mot de passe", + "update_password": "Mettre à jour le mot de passe", + "password_success": "Mot de passe mis à jour avec succès.", + "totp_title": "Authentification à deux facteurs", + "totp_subtitle": "Protégez votre compte avec une application d'authentification. Recommandé : Proton Pass, Aegis (Android) ou Raivo OTP (iOS).", + "totp_on": "Activé", + "totp_off": "Désactivé", + "totp_enable": "Activer le 2FA", + "totp_disable": "Désactiver le 2FA", + "totp_scan_hint": "Scannez le QR code avec votre application d'authentification, puis saisissez le code à 6 chiffres.", + "totp_disable_hint": "Saisissez le code actuel de votre application d'authentification pour désactiver le 2FA.", + "totp_code_label": "Code de confirmation", + "totp_confirm": "Confirmer & activer", + "totp_enabled_success": "2FA activé avec succès.", + "totp_disabled_success": "Le 2FA a été désactivé.", + "totp_invalid_code": "Code invalide. Veuillez réessayer.", + "backup_codes_title": "Enregistrez vos codes de secours", + "backup_codes_hint": "Ces codes ne sont affichés qu'une seule fois. Conservez-les en lieu sûr.", + "backup_copy": "Copier", + "backup_copied": "Copié !", + "backup_download_pdf": "Télécharger en PDF", + "backup_saved": "Je les ai enregistrés", + "danger_zone": "Zone dangereuse", + "danger_text": "Supprimez définitivement votre compte et toutes les données associées. Cette action est irréversible.", + "delete_account": "Supprimer le compte", + "delete_account_confirm": "Supprimer le compte ?", + "delete_account_text": "Toutes vos données seront définitivement supprimées. Cette action est irréversible.", + "languages": { + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "en": "English" + }, + "errors": { + "password_empty": "Le mot de passe ne peut pas être vide.", + "passwords_mismatch": "Les mots de passe ne correspondent pas.", + "password_too_short": "Le mot de passe doit comporter au moins 8 caractères.", + "password_failed": "Échec de la mise à jour du mot de passe." + } + } +} diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json new file mode 100644 index 0000000..2774c62 --- /dev/null +++ b/frontend/src/assets/i18n/it.json @@ -0,0 +1,404 @@ +{ + "common": { + "save_changes": "Salva modifiche", + "cancel": "Annulla", + "delete": "Elimina", + "edit": "Modifica", + "create": "Crea", + "add": "Aggiungi", + "close": "Chiudi", + "save": "Salva", + "name": "Nome", + "optional": "opzionale", + "delete_confirm_title": "Eliminare la voce?", + "delete_confirm_text": "Questa azione non può essere annullata.", + "no_accounts_title": "Nessun conto disponibile", + "no_accounts_text": "Per creare una voce, devi prima configurare un conto.", + "go_to_accounts": "Vai ai conti" + }, + "auth": { + "sign_in": "Accedi", + "sign_up": "Registrati", + "signing_in": "Accesso in corso...", + "creating_account": "Creazione account...", + "username": "Nome utente", + "email": "E-mail", + "password": "Password", + "confirm_password": "Conferma password", + "tagline_login": "Accedi al tuo account", + "tagline_register": "Crea il tuo account", + "no_account": "Non hai un account?", + "create_account": "Crea un account", + "has_account": "Hai già un account?", + "password_hint": "Almeno 8 caratteri.", + "username_hint": "Utilizzato come nome visualizzato nell'app.", + "totp_title": "Autenticazione a due fattori", + "totp_hint": "Inserisci il codice a 6 cifre dalla tua app di autenticazione.", + "totp_or_backup": "Oppure inserisci un codice di backup.", + "totp_no_device": "Non ho il mio dispositivo 2FA", + "totp_use_backup": "Usa il codice di recupero", + "totp_no_backup": "Non ho nemmeno un codice di recupero", + "backup_format_hint": "Formato: XXXXXXXX-XXXXXXXX", + "recovery_title": "Recupero account", + "recovery_intro": "Per mantenere il tuo account sicuro, vogliamo assicurarci che tu sia davvero tu. Un codice di conferma verrà inviato alla tua e-mail di recupero registrata.", + "recovery_send": "Invia codice di conferma", + "recovery_sent": "Un'e-mail con un codice di conferma è stata appena inviata a:", + "recovery_spam_hint": "Se non trovi il messaggio nella tua casella di posta, controlla la cartella spam.", + "recovery_confirm": "Conferma codice", + "recovery_verifying": "Verifica del link in corso…", + "recovery_success": "2FA disattivato. Reindirizzamento in corso…", + "recovery_redirecting": "Reindirizzamento al dashboard…", + "recovery_error": "Il link non è valido o è scaduto.", + "keep_signed_in": "Rimani connesso", + "keep_signed_in_hint": "Consigliato sui dispositivi affidabili.", + "back_to_login": "Torna al login", + "errors": { + "fields_required": "Tutti i campi sono obbligatori.", + "passwords_mismatch": "Le password non corrispondono.", + "password_too_short": "La password deve contenere almeno 8 caratteri.", + "invalid_credentials": "Nome utente o password non validi.", + "enter_credentials": "Inserisci nome utente e password.", + "registration_failed": "Registrazione fallita.", + "invalid_totp": "Codice non valido o scaduto.", + "captcha_failed": "Verifica di sicurezza fallita. Riprova.", + "reset_failed": "Link non valido o scaduto.", + "token_missing": "Nessun token trovato.", + "verify_failed": "Link di verifica non valido o scaduto." + }, + "forgot_password": "Password dimenticata?", + "forgot_password_tagline": "Reimposta password", + "forgot_password_hint": "Inserisci il tuo indirizzo e-mail. Se esiste un account, ti invieremo un link di reimpostazione.", + "send_reset_link": "Invia link di reimpostazione", + "sending": "Invio in corso...", + "reset_link_sent": "Link inviato", + "reset_link_sent_hint": "Se esiste un account con questo indirizzo, riceverai un'e-mail. Controlla anche la cartella spam.", + "reset_password": "Reimposta password", + "reset_password_tagline": "Imposta nuova password", + "reset_password_hint": "Inserisci la tua nuova password.", + "new_password": "Nuova password", + "resetting": "Reimpostazione in corso...", + "reset_success": "Password modificata con successo.", + "verifying": "Verifica dell'indirizzo e-mail in corso...", + "email_verified": "Indirizzo e-mail verificato.", + "verify_email_error": "Verifica non riuscita" + }, + "settings": { + "subtitle": "Sicurezza e impostazioni dell'account", + "recovery_email": "E-mail di recupero", + "recovery_email_hint": "Questa e-mail viene utilizzata per il recupero 2FA. Usa un indirizzo diverso dalla tua e-mail di accesso.", + "recovery_email_saved": "E-mail salvata.", + "sessions_title": "Sessioni attive", + "sessions_hint": "Tutti i dispositivi su cui sei attualmente connesso.", + "sessions_current": "Sessione corrente", + "sessions_revoke": "Disconnetti", + "sessions_revoke_all": "Disconnetti tutte le altre", + "sessions_loading": "Caricamento sessioni…", + "export_title": "Esporta dati", + "export_hint": "Scarica una cartella ZIP con tutti i tuoi dati in formato PDF.", + "export_btn": "Scarica esportazione", + "export_loading": "Preparazione esportazione…", + "notif_title": "Notifiche", + "notif_hint": "Scegli di cosa vuoi essere informato.", + "notif_deadlines": "Scadenze imminenti", + "notif_deadlines_hint": "Promemoria per appuntamenti e scadenze imminenti.", + "notif_budget_alerts": "Avvisi budget", + "notif_budget_alerts_hint": "Notifica quando un budget viene superato.", + "notif_monthly_summary": "Riepilogo mensile", + "notif_monthly_summary_hint": "Panoramica di entrate e uscite a fine mese.", + "notif_saved": "Impostazioni salvate.", + "delete_step1_title": "Esporta prima i tuoi dati", + "delete_step1_hint": "Scarica i tuoi dati prima di eliminare l'account. Questa azione non può essere annullata.", + "delete_step1_btn": "Esporta dati", + "delete_step2_exported": "Esportazione scaricata", + "delete_password_label": "Conferma la tua password", + "delete_phrase_label": "Digita per confermare:", + "delete_wrong_password": "La password non è corretta.", + "sessions_unknown_device": "Dispositivo sconosciuto" + }, + "nav": { + "search_placeholder": "Cerca...", + "no_results": "Nessun risultato trovato.", + "notifications": "Notifiche", + "no_notifications": "Nessuna nuova notifica.", + "mark_read": "Segna come letto", + "mark_all_read": "Segna tutto come letto", + "more_coming_soon": "Altre funzionalità in arrivo", + "sign_out": "Esci", + "profile": "Profilo", + "settings": "Impostazioni", + "dark_mode": "Modalità scura", + "light_mode": "Modalità chiara" + }, + "search": { + "accounts": "Conti", + "budgets": "Budget", + "expenses": "Spese", + "transactions": "Transazioni", + "deadlines": "Scadenze" + }, + "sidebar": { + "dashboard": "Dashboard", + "budgets": "Budget", + "fixed_costs": "Costi fissi", + "expenses": "Spese", + "calendar": "Calendario", + "accounts": "Conti", + "revenue_accounts": "Conti entrate", + "transactions": "Transazioni" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Panoramica finanziaria", + "total_income": "Entrate totali", + "fixed_costs": "Costi fissi", + "expenses": "Spese", + "balance": "Saldo", + "per_month": "CHF / mese", + "chf_total": "CHF totale", + "chf_remaining": "CHF rimanenti", + "income_vs_expenses": "Entrate vs. Spese {{ year }}", + "fixed_costs_breakdown": "Ripartizione costi fissi", + "savings_rate": "Tasso di risparmio", + "of_income": "delle entrate", + "goal": "Obiettivo di risparmio", + "goal_hint": "Consigliato: risparmiare almeno il 20% delle entrate.", + "recent_expenses": "Spese recenti", + "no_expenses": "Nessuna spesa registrata.", + "series_income": "Entrate", + "series_fixed_costs": "Costi fissi", + "series_expenses": "Spese variabili", + "view_report": "Visualizza rapporto", + "greeting_morning": "Buongiorno", + "greeting_afternoon": "Buon pomeriggio", + "greeting_evening": "Buona sera", + "greeting_night": "Buona notte" + }, + "accounts": { + "title": "Tutti i conti", + "add": "Aggiungi conto", + "col_type": "Tipo", + "col_balance": "Saldo (CHF)", + "no_accounts": "Nessun conto ancora.", + "create_title": "Crea conto", + "edit_title": "Modifica conto", + "label_balance": "Saldo (CHF)", + "label_type": "Tipo di conto", + "type_asset": "Attivo", + "type_revenue": "Entrate", + "placeholder_name": "es. Conto risparmio" + }, + "budgets": { + "title": "Costi fissi & Budget", + "subtitle": "Spese totali: ", + "add": "Aggiungi", + "new_entry": "Nuova voce — {{ category }}", + "edit_entry": "Modifica voce", + "label_amount": "Importo (CHF)", + "label_category": "Categoria", + "label_account": "Conto", + "label_active": "Attivo", + "label_suggestions": "Suggerimenti", + "no_entries": "Nessuna voce in questa categoria.", + "entries_count": "({{ count }} voci)", + "placeholder_name": "es. Affitto", + "categories": { + "fixed_expenses": "Spese fisse", + "mobile_internet": "Mobile & Internet", + "subscriptions": "Abbonamenti", + "leisure": "Tempo libero", + "tax_reserves": "Riserve fiscali", + "insurance": "Assicurazioni", + "loans": "Prestiti & Crediti" + } + }, + "expenses": { + "title": "Spese", + "total": "Totale:", + "add": "Aggiungi spesa", + "col_date": "Data", + "col_name": "Nome", + "col_category": "Categoria", + "col_account": "Conto", + "col_amount": "Importo", + "no_expenses": "Nessuna spesa registrata.", + "create_title": "Aggiungi spesa", + "edit_title": "Modifica spesa", + "label_amount": "Importo (CHF)", + "label_date": "Data", + "label_category": "Categoria", + "label_account": "Conto", + "label_due_date": "Data di scadenza", + "label_notes": "Note", + "placeholder_name": "es. Migros", + "categories": { + "groceries": "Spesa alimentare", + "dining": "Ristoranti", + "transport": "Trasporti", + "health": "Salute & Medicina", + "clothing": "Abbigliamento", + "electronics": "Elettronica", + "household": "Casa", + "entertainment": "Intrattenimento", + "travel": "Viaggi", + "other": "Altro" + } + }, + "transactions": { + "title": "Tutte le transazioni", + "add": "Aggiungi transazione", + "col_date": "Data", + "col_description": "Descrizione", + "col_from": "Da", + "col_to": "A", + "col_amount": "Importo", + "no_transactions": "Nessuna transazione ancora.", + "create_title": "Crea transazione", + "edit_title": "Modifica transazione", + "label_description": "Descrizione", + "label_amount": "Importo (CHF)", + "label_date": "Data", + "label_from": "Conto di origine", + "label_to": "Conto di destinazione", + "select_account": "Seleziona conto...", + "placeholder_description": "es. Affitto gennaio" + }, + "calendar": { + "year_view": "Vista annuale", + "subscribe": "Abbonati", + "ical_title": "Il tuo URL del feed iCal", + "ical_desc": "Aggiungi questo URL a qualsiasi app di calendario (Protonmail, Google, Apple, Outlook).", + "ical_copy": "Copia", + "ical_copied": "Copiato!", + "filter_holidays": "Festività", + "filter_school": "Vacanze scolastiche", + "filter_invoices": "Fatture", + "filter_deadlines": "Scadenze", + "add_deadline": "Aggiungi scadenza", + "select_type": "Seleziona motivo...", + "label_title": "Titolo", + "label_date": "Data", + "label_type": "Tipo", + "label_notes": "Note", + "deadline_types": { + "tax": "Tasse", + "insurance": "Assicurazione", + "invoice": "Fattura", + "personal": "Personale", + "other": "Altro" + }, + "months": { + "1": "Gennaio", + "2": "Febbraio", + "3": "Marzo", + "4": "Aprile", + "5": "Maggio", + "6": "Giugno", + "7": "Luglio", + "8": "Agosto", + "9": "Settembre", + "10": "Ottobre", + "11": "Novembre", + "12": "Dicembre" + }, + "weekdays": [ + "Lun", + "Mar", + "Mer", + "Gio", + "Ven", + "Sab", + "Dom" + ], + "no_events": "Nessun evento in questo giorno.", + "more_events": "+{{ count }} altri", + "legend_national": "Festa nazionale", + "legend_cantonal": "Festa cantonale", + "legend_school": "Vacanze scolastiche", + "legend_expense": "Data di scadenza", + "legend_personal": "Scadenza personale" + }, + "canton_names": { + "AG": "Argovia", + "AI": "Appenzello Interno", + "AR": "Appenzello Esterno", + "BE": "Berna", + "BL": "Basilea Campagna", + "BS": "Basilea Città", + "FR": "Friburgo", + "GE": "Ginevra", + "GL": "Glarona", + "GR": "Grigioni", + "JU": "Giura", + "LU": "Lucerna", + "NE": "Neuchâtel", + "NW": "Nidvaldo", + "OW": "Obvaldo", + "SG": "San Gallo", + "SH": "Sciaffusa", + "SO": "Soletta", + "SZ": "Svitto", + "TG": "Turgovia", + "TI": "Ticino", + "UR": "Uri", + "VD": "Vaud", + "VS": "Vallese", + "ZG": "Zugo", + "ZH": "Zurigo" + }, + "profile": { + "title": "Profilo", + "subtitle": "Gestisci le tue informazioni personali e impostazioni", + "personal_info": "Informazioni personali", + "profile_photo": "Foto profilo", + "photo_hint": "Clicca sull'avatar per caricare una foto", + "fallback_color": "Colore di riserva", + "first_name": "Nome", + "last_name": "Cognome", + "email": "E-mail", + "canton": "Cantone", + "language": "Lingua", + "save_changes": "Salva modifiche", + "save_success": "Profilo salvato con successo.", + "change_password": "Cambia password", + "new_password": "Nuova password", + "confirm_password": "Conferma password", + "update_password": "Aggiorna password", + "password_success": "Password aggiornata con successo.", + "totp_title": "Autenticazione a due fattori", + "totp_subtitle": "Proteggi il tuo account con un'app di autenticazione. Consigliato: Proton Pass, Aegis (Android) o Raivo OTP (iOS).", + "totp_on": "Attivo", + "totp_off": "Disattivo", + "totp_enable": "Attiva 2FA", + "totp_disable": "Disattiva 2FA", + "totp_scan_hint": "Scansiona il codice QR con la tua app di autenticazione, poi inserisci il codice a 6 cifre.", + "totp_disable_hint": "Inserisci il codice attuale dalla tua app di autenticazione per disattivare il 2FA.", + "totp_code_label": "Codice di conferma", + "totp_confirm": "Conferma e attiva", + "totp_enabled_success": "2FA attivato con successo.", + "totp_disabled_success": "Il 2FA è stato disattivato.", + "totp_invalid_code": "Codice non valido. Riprova.", + "backup_codes_title": "Salva i tuoi codici di backup", + "backup_codes_hint": "Questi codici vengono mostrati una sola volta. Conservali in un posto sicuro.", + "backup_copy": "Copia", + "backup_copied": "Copiato!", + "backup_download_pdf": "Scarica come PDF", + "backup_saved": "Li ho salvati", + "danger_zone": "Zona pericolosa", + "danger_text": "Elimina definitivamente il tuo account e tutti i dati associati. Questa azione non può essere annullata.", + "delete_account": "Elimina account", + "delete_account_confirm": "Eliminare l'account?", + "delete_account_text": "Tutti i tuoi dati saranno eliminati definitivamente. Questa azione non può essere annullata.", + "languages": { + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "en": "English" + }, + "errors": { + "password_empty": "La password non può essere vuota.", + "passwords_mismatch": "Le password non corrispondono.", + "password_too_short": "La password deve contenere almeno 8 caratteri.", + "password_failed": "Aggiornamento password fallito." + } + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..ea2509d --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,14 @@ + + + + + Armarium + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..6e0b097 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,32 @@ +@import '@fontsource/roboto/300.css'; +@import '@fontsource/roboto/400.css'; +@import '@fontsource/roboto/500.css'; +@import '@fontsource/roboto/700.css'; +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + font-family: 'Roboto', 'ui-sans-serif', 'system-ui', '-apple-system', 'Helvetica Neue', 'Arial', 'sans-serif'; + font-size: 0.875rem; /* 14px Desktop */ + line-height: 1.375rem; + } + + @media (max-width: 640px) { + body { + font-size: 0.9375rem; /* 15px Mobile */ + line-height: 1.5rem; + } + } + + h1 { font-size: 1.75rem; line-height: 2.25rem; font-weight: 700; } /* 28px */ + h2 { font-size: 1.375rem; line-height: 2rem; font-weight: 600; } /* 22px */ + h3 { font-size: 1.125rem; line-height: 1.75rem; font-weight: 600; } /* 18px */ + + @media (max-width: 640px) { + h1 { font-size: 1.5rem; line-height: 2rem; } /* 24px Mobile */ + h2 { font-size: 1.25rem; line-height: 1.75rem; } /* 20px Mobile */ + h3 { font-size: 1.0625rem; line-height: 1.5rem; } /* 17px Mobile */ + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..5f96743 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,34 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: 'class', + content: [ + "./src/**/*.{html,ts}", + ], + safelist: [ + 'w-16', + 'w-64', + 'ml-16', + 'ml-64', + '-translate-x-full', + ], + theme: { + extend: { + fontFamily: { + sans: ['Roboto', 'ui-sans-serif', 'system-ui', '-apple-system', 'Helvetica Neue', 'Arial', 'sans-serif'], + }, + fontSize: { + // Tooltips / Helper Text + '2xs': ['0.6875rem', { lineHeight: '1rem' }], // 11px + 'xs': ['0.75rem', { lineHeight: '1rem' }], // 12px Labels/Captions + // Body / Buttons / Navigation + 'sm': ['0.875rem', { lineHeight: '1.375rem' }], // 14px Desktop standard + 'base':['0.9375rem', { lineHeight: '1.5rem' }], // 15px Mobile Body + // Headings + 'h3': ['1.125rem', { lineHeight: '1.75rem' }], // 18px + 'h2': ['1.375rem', { lineHeight: '2rem' }], // 22px + 'h1': ['1.75rem', { lineHeight: '2.25rem' }], // 28px Desktop + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..264f459 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2ab7442 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..d383706 --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..41d2947 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + + # Angular frontend (static build) + root /var/www/armarium/frontend; + index index.html; + + # API and media: proxy to Django/Gunicorn + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /media/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Angular SPA: all other requests serve index.html + location / { + try_files $uri $uri/ /index.html; + } +}