diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d2c1540 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# CapMark — configuración. La app funciona 100% en local SIN estas variables. +# Rellénalas solo si quieres sincronización multi-dispositivo con tu backend auto-instanciado. +# Genera este archivo como .env con: ./scripts/bootstrap.sh + +# --- Backend (Supabase self-hosted; ver infra/docker-compose.yml) --- +VITE_SUPABASE_URL=http://localhost:8000 +VITE_SUPABASE_ANON_KEY= + +# --- Edge Functions --- +# Verificación de links del lado servidor (RF-017, evita CORS) +VITE_VERIFY_URL=http://localhost:8000/functions/v1/verify-link +# Proxy para el scraper opt-in (descarga el HTML sin CORS) +VITE_SCRAPER_PROXY=http://localhost:8000/functions/v1/fetch-html + +# --- Secretos del backend (usados por docker-compose, NO por la app cliente) --- +POSTGRES_PASSWORD=change-me-please +JWT_SECRET=change-me-a-32-char-min-secret-string +ANON_KEY= +SERVICE_ROLE_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa1c0f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Tests + run: npm test + - name: Build + run: npm run build + + backend-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validar compose y migraciones + run: | + docker compose -f infra/docker-compose.yml config >/dev/null + test -f infra/migrations/0001_init.sql + echo "Compose y migraciones OK" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d66c081 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store + +# Capacitor +android/ +ios/ + +# Supabase self-hosted volumes +infra/volumes/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bbbe4d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CapMark — guía para trabajar en este repo + +Tracker local-first de manga/manhua/novelas (Ionic React + Capacitor + TypeScript). +Backend de sincronización **opcional y auto-instanciable** (Supabase self-hosted por Docker). + +## Comandos + +- `npm run dev` — app (Vite, http://localhost:5173) +- `npm test` — Vitest (`src/**/*.test.ts`) +- `npm run typecheck` — `tsc --noEmit` +- `npm run build` — typecheck + build +- `npm run bootstrap` — levanta el backend con secretos + migraciones (no manual) +- `make check` — lo que corre CI (typecheck + test + build) + +## Arquitectura por capas (respetar RNF-009) + +Dependencias **solo hacia adentro**. No romper esta dirección: + +``` +ui → application → domain + ↑ + infrastructure (implementa los puertos de application; se inyecta en el container) +``` + +- `src/domain` — entidades, tipos, reglas puras. **No importa nada** de otras capas. +- `src/application` — casos de uso (`*-service.ts`) y **puertos** (`ports.ts`, interfaces). + No conoce Ionic, Dexie ni Supabase. +- `src/infrastructure` — adaptadores concretos (Dexie, verificador HTTP, scraper, sync) y el + composition root `container.ts` (único sitio que instancia adaptadores). +- `src/ui` — Ionic React. Habla con `container`, nunca con adaptadores directamente. + +Aliases de import: `@domain`, `@application`, `@infrastructure`, `@ui`, `@test`. + +## Convenciones + +- Nombres de dominio en español (Obra, Fuente, Progreso), consistente con `Docs/`. +- Cada regla enlaza su requisito en comentarios (p. ej. `// RF-009`). Ver `Docs/02` y `Docs/03`. +- Lógica nueva con reglas → va en `domain` con su test; orquestación → `application`. +- Persistencia: implementar el puerto `Repositorio`; hoy Dexie (web), SQLite en nativo. +- El **scraper es opt-in y semi-asistido** (decisión de producto, S2): PROPONE, nunca guarda solo. + +## Backend + +`infra/docker-compose.yml` + `infra/migrations/*.sql` (se aplican al primer arranque) + +`infra/functions/*` (Edge Functions). Secretos en `.env` (generado por `bootstrap.sh`). +La app funciona sin backend; `container.sync.disponible()` decide si hay sincronización. + +## Plan + +Roadmap por fases en `Docs/06-plan-de-trabajo.md`. Sync completo (outbox + Realtime) = Fase 3. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c2142e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: help install dev build test check bootstrap up down logs + +help: ## Muestra esta ayuda + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +install: ## Instala dependencias + npm install + +dev: ## App en desarrollo (http://localhost:5173) + npm run dev + +build: ## Typecheck + build de producción + npm run build + +test: ## Tests unitarios + npm test + +check: ## Typecheck + tests + build (lo que corre CI) + npm run typecheck && npm test && npm run build + +bootstrap: ## Auto-instancia el backend (secretos + compose + migraciones) + ./scripts/bootstrap.sh + +up: ## Levanta el backend + npm run backend:up + +down: ## Detiene el backend + npm run backend:down + +logs: ## Sigue los logs del backend + npm run backend:logs diff --git a/README.md b/README.md index f29c301..9e27757 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# CapMark- \ No newline at end of file +# CapMark — Gestor de Lecturas + +Tracker personal y multiplataforma de **manga, manhua y novelas**. No pierdas el punto de +lectura aunque los sitios cambien de nombre o se caigan: cada obra guarda **varias fuentes**, +su **capítulo actual con historial**, su **estado** y su **prioridad**. + +> Es un *tracker*, no un lector: la fuente se abre en el navegador externo. Diseño +> **local-first** — funciona sin conexión y sin backend; la sincronización es opcional. + +## Características + +- **Catálogo** de obras: título, tipo, nombres alternativos, tags, estado y prioridad. +- **Fuentes por obra** con nombre-en-el-sitio, URL y marca de fuente principal. +- **Progreso e historial** fechado; capítulos con decimales (p. ej. `179.5`). +- **Búsqueda** por título/alias y **filtros** combinables por tag, estado y prioridad. +- **Verificación de fuentes** (opt-in): comprueba si un link responde y señala las caídas. +- **Detección de capítulo** (opt-in, semi-asistida): lee una fuente y **propone** el capítulo; + tú confirmas antes de guardar. Nunca actualiza solo. +- **Backend auto-instanciable**: un comando levanta tu propia sincronización, sin paneles. + +## Arranque rápido (app, sin backend) + +```bash +npm install +npm run dev # http://localhost:5173 +``` + +La app persiste en el navegador (IndexedDB). Pulsa **"Cargar datos de ejemplo"** para probarla. + +## Backend auto-instanciable (opcional, para sincronizar) + +No hay que configurar nada a mano. Un comando genera secretos, levanta Postgres + Auth + +Realtime + Edge Functions y **aplica las migraciones solo**: + +```bash +./scripts/bootstrap.sh # o: npm run bootstrap +``` + +Luego rellena en `.env` las claves que imprime el arranque y reinicia `npm run dev`. +Parar: `npm run backend:down`. + +Requisitos: Docker (con Compose) y OpenSSL. Ver `infra/` y `.env.example`. + +## Móvil (Capacitor) + +```bash +npm run build +npx cap add android # y/o: npx cap add ios +npx cap sync +npx cap open android +``` + +## Arquitectura (separación por capas — RNF-009) + +``` +src/ + domain/ Entidades, tipos e invariantes. Sin dependencias externas. + application/ Casos de uso + puertos (interfaces). No conoce Ionic ni Supabase. + infrastructure/ Adaptadores: Dexie/IndexedDB, verificador, scraper, sync, DI. + ui/ Ionic React (páginas y componentes). +infra/ Backend self-hosted como código (compose, migraciones, functions). +``` + +El dominio no importa infraestructura: el backend de sync es sustituible sin tocar la lógica. + +## Scripts + +| Comando | Qué hace | +|---------|----------| +| `npm run dev` | App en desarrollo (Vite) | +| `npm run build` | Typecheck + build de producción | +| `npm test` | Tests unitarios (Vitest) | +| `npm run bootstrap` | Auto-instancia el backend | +| `npm run backend:up` / `:down` / `:logs` | Controla el backend | + +## Documentación + +Los documentos fundacionales (visión, alcance, requisitos, features, stack y plan de +trabajo) están en [`Docs/`](./Docs). + +## Estado + +MVP en construcción. La sincronización (outbox + Realtime) es la Fase 3 del plan +(`Docs/06-plan-de-trabajo.md`); el adaptador está preparado como puerto. diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..48c5201 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,12 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.capmark.app', + appName: 'CapMark', + webDir: 'dist', + server: { + androidScheme: 'https', + }, +}; + +export default config; diff --git a/index.html b/index.html new file mode 100644 index 0000000..aed276f --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + CapMark — Gestor de Lecturas + + +
+ + + diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..ddf73d7 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,102 @@ +# Backend auto-instanciable para CapMark (Supabase self-hosted, versión enfocada). +# Un solo comando lo levanta: `cd infra && docker compose up -d`. +# El esquema y las políticas RLS se aplican SOLOS al primer arranque de Postgres +# (todo en ./migrations se ejecuta desde /docker-entrypoint-initdb.d). +# +# Nada de paneles manuales: los secretos vienen del .env de la raíz (ver .env.example). + +name: capmark + +services: + db: + image: supabase/postgres:15.1.1.78 + restart: unless-stopped + ports: + - '5432:5432' + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: postgres + volumes: + - ./volumes/db:/var/lib/postgresql/data + # Migraciones versionadas: se aplican en orden alfabético en la primera inicialización. + - ./migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'postgres'] + interval: 5s + timeout: 5s + retries: 10 + + # API REST autogenerada sobre el esquema (PostgREST). Es la API de backup/restore: + # inserta/lee filas respetando RLS con el JWT que emite GoTrue. + rest: + image: postgrest/postgrest:v12.2.3 + restart: unless-stopped + ports: + - '3000:3000' # http://localhost:3000/obra , /fuente , /progreso + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET:-super-secret-jwt-token-with-at-least-32-chars} + + # Autenticación (GoTrue): cuentas y sesiones (RF-015, RNF-006). + auth: + image: supabase/gotrue:v2.158.1 + restart: unless-stopped + ports: + - '9999:9999' # http://localhost:9999 (signup/login → JWT) + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + PORT: 9999 + API_EXTERNAL_URL: http://localhost:9999 + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres + GOTRUE_SITE_URL: http://localhost:5173 + GOTRUE_URI_ALLOW_LIST: '*' + GOTRUE_JWT_SECRET: ${JWT_SECRET:-super-secret-jwt-token-with-at-least-32-chars} + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_DISABLE_SIGNUP: 'false' + # Sin servidor de correo en local: autoconfirma el alta para poder usar la cuenta ya. + GOTRUE_MAILER_AUTOCONFIRM: 'true' + GOTRUE_EXTERNAL_EMAIL_ENABLED: 'true' + GOTRUE_EXTERNAL_PHONE_ENABLED: 'false' + + # Sincronización en tiempo real (RNF-005: cambios < 10 s). Solo para la fase de sync en + # vivo (Fase 3); NO es necesario para backup/restore. Requiere config adicional (tenant, + # claves). Arranca solo con: docker compose --profile full up -d + realtime: + image: supabase/realtime:v2.33.58 + profiles: ['full'] + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + DB_NAME: postgres + API_JWT_SECRET: ${JWT_SECRET:-super-secret-jwt-token-with-at-least-32-chars} + + # Edge Functions (Deno): verificación de links y proxy del scraper (RF-017, RNF-008). + # Requiere un router `main` (falta); no es necesario para backup/restore. + # Arranca solo con: docker compose --profile full up -d + functions: + image: supabase/edge-runtime:v1.66.5 + profiles: ['full'] + restart: unless-stopped + ports: + - '8000:9000' + volumes: + - ./functions:/home/deno/functions:ro + command: ['start', '--main-service', '/home/deno/functions'] diff --git a/infra/functions/fetch-html/index.ts b/infra/functions/fetch-html/index.ts new file mode 100644 index 0000000..8a947ed --- /dev/null +++ b/infra/functions/fetch-html/index.ts @@ -0,0 +1,28 @@ +// Edge Function: proxy de descarga de HTML para el scraper OPT-IN (evita CORS). +// El extractor de capítulo corre en el cliente sobre el HTML que devuelve esta función. +// GET ?url=https://... + +Deno.serve(async (req: Request) => { + const cors = { 'access-control-allow-origin': '*' }; + if (req.method === 'OPTIONS') return new Response('ok', { headers: cors }); + + const target = new URL(req.url).searchParams.get('url'); + if (!target || !/^https?:\/\//i.test(target)) { + return new Response('url inválida', { status: 400, headers: cors }); + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 8000); + try { + const res = await fetch(target, { + redirect: 'follow', + signal: ctrl.signal, + headers: { 'user-agent': 'Mozilla/5.0 CapMark', accept: 'text/html' }, + }); + const html = await res.text(); + return new Response(html, { headers: { ...cors, 'content-type': 'text/html; charset=utf-8' } }); + } catch { + return new Response('', { status: 502, headers: cors }); + } finally { + clearTimeout(timer); + } +}); diff --git a/infra/functions/verify-link/index.ts b/infra/functions/verify-link/index.ts new file mode 100644 index 0000000..f5e8704 --- /dev/null +++ b/infra/functions/verify-link/index.ts @@ -0,0 +1,33 @@ +// Edge Function: verificación de links del lado servidor (RF-017, RNF-008). +// Evita el CORS del cliente y aplica timeout ≤ 5 s. Responde { ok: boolean }. +// POST { "url": "https://..." } + +Deno.serve(async (req: Request) => { + const cors = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'content-type', + 'access-control-allow-methods': 'POST, OPTIONS', + }; + if (req.method === 'OPTIONS') return new Response('ok', { headers: cors }); + + try { + const { url } = await req.json(); + if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) { + return Response.json({ ok: false, error: 'url inválida' }, { status: 400, headers: cors }); + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 5000); + try { + let res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: ctrl.signal }); + // Algunos sitios no soportan HEAD: reintenta con GET liviano. + if (res.status === 405 || res.status === 501) { + res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal }); + } + return Response.json({ ok: res.ok, status: res.status }, { headers: cors }); + } finally { + clearTimeout(timer); + } + } catch { + return Response.json({ ok: false }, { headers: cors }); + } +}); diff --git a/infra/migrations/0000_prelude.sh b/infra/migrations/0000_prelude.sh new file mode 100755 index 0000000..5111092 --- /dev/null +++ b/infra/migrations/0000_prelude.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# CapMark — prelude de inicialización. Corre ANTES de 0001_init.sql (orden alfabético en +# /docker-entrypoint-initdb.d). Crea lo que la migración de esquema da por hecho pero que +# en este compose "enfocado" nadie provee: +# · el esquema `auth` y los helpers auth.uid()/auth.role() que usan las políticas RLS, +# · los roles que esperan PostgREST/GoTrue/Realtime, CON la contraseña real ($POSTGRES_PASSWORD). +# +# Necesario porque montamos ./migrations sobre /docker-entrypoint-initdb.d, lo que oculta +# los scripts internos de la imagen supabase/postgres que normalmente harían esto. Es un +# .sh (no .sql) porque necesita interpolar el secreto $POSTGRES_PASSWORD del entorno. +set -euo pipefail + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <> 'sub' + )::uuid +\$fn\$; + +create or replace function auth.role() returns text language sql stable as \$fn\$ + select coalesce( + nullif(current_setting('request.jwt.claim.role', true), ''), + nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role' + ) +\$fn\$; + +alter function auth.uid() owner to supabase_auth_admin; +alter function auth.role() owner to supabase_auth_admin; +EOSQL + +echo "✓ prelude: esquema auth + auth.uid()/auth.role() + roles listos" diff --git a/infra/migrations/0001_init.sql b/infra/migrations/0001_init.sql new file mode 100644 index 0000000..b43c80b --- /dev/null +++ b/infra/migrations/0001_init.sql @@ -0,0 +1,88 @@ +-- CapMark — esquema inicial (Fase 1 del plan). +-- Se aplica SOLO en el primer arranque de Postgres (docker-entrypoint-initdb.d). +-- Modelo: Obra 1–N Fuente, Obra 1–N Progreso. RLS por usuario desde el día uno (RNF-006). + +-- Roles mínimos que esperan PostgREST/GoTrue en este compose enfocado. +do $$ begin + if not exists (select from pg_roles where rolname = 'anon') then create role anon nologin; end if; + if not exists (select from pg_roles where rolname = 'authenticated') then create role authenticated nologin; end if; + if not exists (select from pg_roles where rolname = 'authenticator') then + create role authenticator login password 'postgres' noinherit; + end if; + if not exists (select from pg_roles where rolname = 'supabase_auth_admin') then + create role supabase_auth_admin login password 'postgres' createrole; + end if; +end $$; +grant anon, authenticated to authenticator; + +-- --------------------------------------------------------------------------- +create type tipo_obra as enum ('manga', 'manhua', 'novela'); +create type estado_obra as enum ('pendiente', 'leyendo', 'pausado', 'abandonado', 'completado'); +create type prioridad as enum ('alta', 'media', 'baja'); +create type estado_fuente as enum ('activa', 'caida', 'sin_verificar'); + +create table obra ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null default auth.uid(), + titulo text not null check (length(trim(titulo)) > 0), -- RF-001 + tipo tipo_obra not null, + nombres_alt text[] not null default '{}', -- RF-002 + tags text[] not null default '{}', -- RF-003 + estado estado_obra not null default 'pendiente', -- RF-004 + prioridad prioridad not null default 'media', -- RF-005 + notas text, -- RF-007 + creada_en timestamptz not null default now(), + actualizada_en timestamptz not null default now() -- LWW (S5) +); + +create table fuente ( + id uuid primary key default gen_random_uuid(), + obra_id uuid not null references obra(id) on delete cascade, -- RF-006 cascada + user_id uuid not null default auth.uid(), + nombre_sitio text not null, -- RF-008 + url text not null check (url ~* '^https?://'), -- RF-008 + es_principal boolean not null default false, -- RF-009 + estado_verif estado_fuente not null default 'sin_verificar', -- RF-017 + verificada_en timestamptz +); +-- RF-009: como máximo una fuente principal por obra. +create unique index fuente_una_principal on fuente(obra_id) where es_principal; + +create table progreso ( + id uuid primary key default gen_random_uuid(), + obra_id uuid not null references obra(id) on delete cascade, -- RF-006 cascada + user_id uuid not null default auth.uid(), + capitulo numeric not null check (capitulo >= 0), -- RF-011 (admite 10.5) + punto text, + registrado_en timestamptz not null default now() -- RF-012 +); + +-- Índices para RNF-001 (catálogo < 300 ms) y RNF-010 (volumen). +create index obra_user_idx on obra(user_id); +create index obra_estado_idx on obra(user_id, estado); +create index obra_prioridad_idx on obra(user_id, prioridad); +create index fuente_obra_idx on fuente(obra_id); +create index progreso_obra_idx on progreso(obra_id, registrado_en desc); + +-- --------------------------------------------------------------------------- +-- RLS: cada usuario solo ve y edita sus propios datos (RNF-006). +alter table obra enable row level security; +alter table fuente enable row level security; +alter table progreso enable row level security; + +create policy obra_propia on obra + using (user_id = auth.uid()) with check (user_id = auth.uid()); +create policy fuente_propia on fuente + using (user_id = auth.uid()) with check (user_id = auth.uid()); +create policy progreso_propio on progreso + using (user_id = auth.uid()) with check (user_id = auth.uid()); + +grant usage on schema public to anon, authenticated; +grant all on all tables in schema public to authenticated; + +-- Trigger para mantener actualizada_en (base de la resolución LWW). +create or replace function touch_actualizada_en() returns trigger as $$ +begin new.actualizada_en = now(); return new; end; +$$ language plpgsql; +create trigger obra_touch before update on obra + for each row execute function touch_actualizada_en(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fae316d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2588 @@ +{ + "name": "capmark", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "capmark", + "version": "0.1.0", + "dependencies": { + "@ionic/react": "^8.4.0", + "@ionic/react-router": "^8.4.0", + "dexie": "^4.0.10", + "ionicons": "^7.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^5.3.4", + "react-router-dom": "^5.3.4" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-router": "^5.1.20", + "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@ionic/core": { + "version": "8.8.13", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.13.tgz", + "integrity": "sha512-f09pRxmOLxPvLeCK9kTTBiByaPeCrApwABAwkqeax08e1b4kDSyXD1nMGDT6ChTvUGxyt4/cPxLsEP68ku4+HQ==", + "license": "MIT", + "dependencies": { + "@stencil/core": "4.43.5", + "ionicons": "^8.0.13", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/@ionic/core/node_modules/ionicons": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.0.13.tgz", + "integrity": "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==", + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.35.3" + } + }, + "node_modules/@ionic/react": { + "version": "8.8.13", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.8.13.tgz", + "integrity": "sha512-xKevvpmTfi2ZtOcmwEsw3EE6fm+TqKYIHKvgkIwAUz/bnRcMmGA9b474WxJcLQ5Twv8ynj/C1iDQcDA+jR0/pw==", + "license": "MIT", + "dependencies": { + "@ionic/core": "8.8.13", + "ionicons": "^8.0.13", + "tslib": "*" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-dom": ">=16.8.6" + } + }, + "node_modules/@ionic/react-router": { + "version": "8.8.13", + "resolved": "https://registry.npmjs.org/@ionic/react-router/-/react-router-8.8.13.tgz", + "integrity": "sha512-9+EKIsqa1+MyzyY/sDkhuMc1cWwtGcqrKzsxnL94bQHuPt3dndRbLzCJZiiaDwffahoiRCRKt2Xv6aVVQWAhmQ==", + "license": "MIT", + "dependencies": { + "@ionic/react": "8.8.13", + "tslib": "*" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-dom": ">=16.8.6", + "react-router": "^5.0.1", + "react-router-dom": "^5.0.1" + } + }, + "node_modules/@ionic/react/node_modules/ionicons": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.0.13.tgz", + "integrity": "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==", + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.35.3" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stencil/core": { + "version": "4.43.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", + "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.10.0" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "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.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dexie": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", + "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "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/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/ionicons": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz", + "integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==", + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.0.3" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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/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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/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/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.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/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-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff503ad --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "capmark", + "version": "0.1.0", + "private": true, + "description": "Gestor de Lecturas — tracker multiplataforma de manga/manhua/novelas (local-first, backend auto-instanciable)", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "bootstrap": "bash scripts/bootstrap.sh", + "backend:up": "docker compose --env-file .env -f infra/docker-compose.yml up -d", + "backend:down": "docker compose -f infra/docker-compose.yml down", + "backend:logs": "docker compose -f infra/docker-compose.yml logs -f" + }, + "dependencies": { + "@ionic/react": "^8.4.0", + "@ionic/react-router": "^8.4.0", + "dexie": "^4.0.10", + "ionicons": "^7.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^5.3.4", + "react-router-dom": "^5.3.4" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-router": "^5.1.20", + "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } +} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..caca9df --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# CapMark — auto-instanciación del backend. Idempotente: puedes ejecutarlo varias veces. +# Uso: ./scripts/bootstrap.sh +# Requisitos: docker (con compose) y openssl. La app web funciona SIN esto (local-first). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT/.env" + +echo "▶ CapMark bootstrap" + +# 1. Genera .env con secretos si no existe (nada de configuración manual en paneles). +if [[ ! -f "$ENV_FILE" ]]; then + echo " · Generando .env con secretos aleatorios…" + cp "$ROOT/.env.example" "$ENV_FILE" + PG_PASS="$(openssl rand -hex 16)" + JWT="$(openssl rand -hex 32)" + # Reemplazos portables (BSD/GNU sed). + sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${PG_PASS}|" "$ENV_FILE" + sed -i.bak "s|^JWT_SECRET=.*|JWT_SECRET=${JWT}|" "$ENV_FILE" + rm -f "$ENV_FILE.bak" + echo " · .env creado. Revisa VITE_SUPABASE_ANON_KEY si activas sync." +else + echo " · .env ya existe; no se sobreescribe." +fi + +# 2. Verifica docker. +if ! command -v docker >/dev/null 2>&1; then + echo "✗ Docker no está instalado. La app web funciona igual con: npm install && npm run dev" + exit 0 +fi + +# 3. Levanta el backend (db + auth + rest). Las migraciones se aplican solas al primer +# arranque. Realtime y Edge Functions quedan tras el perfil `full` (sync en vivo, Fase 3): +# para activarlos: docker compose --profile full up -d +echo " · Levantando backend (Postgres + Auth + REST)…" +docker compose --env-file "$ENV_FILE" -f "$ROOT/infra/docker-compose.yml" up -d + +# 4. Ajuste post-arranque: durante sus migraciones GoTrue sobrescribe auth.uid() con una +# versión que lee 'request.jwt.claim.sub' (PostgREST < v11). Con PostgREST v12 eso devuelve +# NULL y RLS bloquea todo. Esperamos a que la API de GoTrue esté arriba (⇒ migraciones +# aplicadas) y volvemos a fijar la versión que lee 'request.jwt.claims' (JSON). Idempotente. +echo " · Esperando a GoTrue para ajustar auth.uid()…" +for _ in $(seq 1 60); do + curl -sf http://localhost:9999/health >/dev/null 2>&1 && break + sleep 1 +done +docker compose --env-file "$ENV_FILE" -f "$ROOT/infra/docker-compose.yml" exec -T db \ + psql -v ON_ERROR_STOP=1 -U postgres -d postgres <<'SQL' >/dev/null 2>&1 && echo " · auth.uid() ajustado (compatible PostgREST v12)." +create or replace function auth.uid() returns uuid language sql stable as $fn$ + select coalesce( + nullif(current_setting('request.jwt.claim.sub', true), ''), + nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub' + )::uuid +$fn$; +alter function auth.uid() owner to supabase_auth_admin; +SQL + +echo "✓ Listo." +echo " · REST (backup/restore): http://localhost:3000 · Auth: http://localhost:9999 · Postgres: localhost:5432" +echo " · App: npm install && npm run dev (http://localhost:5173)" diff --git a/src/application/backup-service.test.ts b/src/application/backup-service.test.ts new file mode 100644 index 0000000..c7fd40f --- /dev/null +++ b/src/application/backup-service.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { BackupService } from './backup-service'; +import { CatalogoService } from './catalogo-service'; +import { FuenteService } from './fuente-service'; +import { ProgresoService } from './progreso-service'; +import { fakeIdGen, fakeReloj, fakeVerifier, MemoryRepositorio } from '@test/support'; + +describe('BackupService (modo absolutamente local)', () => { + it('exporta e importa un round-trip completo en un repo limpio', async () => { + const src = new MemoryRepositorio(); + const cat = new CatalogoService(src, fakeIdGen(), fakeReloj()); + const fue = new FuenteService(src, fakeIdGen(), fakeVerifier()); + const pro = new ProgresoService(src, fakeIdGen(), fakeReloj()); + + const o = await cat.crear({ titulo: 'Berserk', tipo: 'manga', tags: ['seinen'] }); + await fue.agregar(o.id, { nombreEnSitio: 'A', url: 'https://a.com' }); + await pro.registrar(o.id, { capitulo: 364 }); + + const backup = await new BackupService(src).exportar(); + expect(backup.obras).toHaveLength(1); + + // Restaura en un repo vacío distinto. + const dst = new MemoryRepositorio(); + const res = await new BackupService(dst).importar(backup); + expect(res.obras).toBe(1); + expect(await dst.obtenerObra(o.id)).toMatchObject({ titulo: 'Berserk' }); + expect(await dst.listarFuentes(o.id)).toHaveLength(1); + expect((await dst.listarProgreso(o.id))[0].capitulo).toBe(364); + }); + + it('rechaza archivos que no son backups de CapMark', async () => { + const svc = new BackupService(new MemoryRepositorio()); + // @ts-expect-error forma inválida a propósito + await expect(svc.importar({ foo: 'bar' })).rejects.toThrow(); + }); + + it('reimportar no duplica fuentes ni progreso', async () => { + const repo = new MemoryRepositorio(); + const cat = new CatalogoService(repo, fakeIdGen(), fakeReloj()); + const fue = new FuenteService(repo, fakeIdGen(), fakeVerifier()); + const pro = new ProgresoService(repo, fakeIdGen(), fakeReloj()); + const o = await cat.crear({ titulo: 'X', tipo: 'manga' }); + await fue.agregar(o.id, { nombreEnSitio: 'A', url: 'https://a.com' }); + await pro.registrar(o.id, { capitulo: 1 }); + + const backup = await new BackupService(repo).exportar(); + const svc = new BackupService(repo); + await svc.importar(backup); + await svc.importar(backup); + expect(await repo.listarFuentes(o.id)).toHaveLength(1); + expect(await repo.listarProgreso(o.id)).toHaveLength(1); + }); +}); diff --git a/src/application/backup-service.ts b/src/application/backup-service.ts new file mode 100644 index 0000000..c58d7fe --- /dev/null +++ b/src/application/backup-service.ts @@ -0,0 +1,49 @@ +import { Fuente } from '@domain/fuente'; +import { Obra } from '@domain/obra'; +import { ProgresoEntry } from '@domain/progreso'; +import { Repositorio } from './ports'; + +/** + * Backup/restore 100% local (modo absolutamente local, sin Supabase). Permite poseer los + * datos y moverlos entre dispositivos manualmente por archivo, sin backend alguno. + */ +export interface Backup { + app: 'capmark'; + version: 1; + exportadoEn: string; + obras: { obra: Obra; fuentes: Fuente[]; progreso: ProgresoEntry[] }[]; +} + +export class BackupService { + constructor(private repo: Repositorio) {} + + /** Exporta todo el catálogo con sus fuentes e historial a un objeto serializable. */ + async exportar(): Promise { + const obras = await this.repo.listarObras(); + const detalle = await Promise.all( + obras.map(async (obra) => ({ + obra, + fuentes: await this.repo.listarFuentes(obra.id), + progreso: await this.repo.listarProgreso(obra.id), + })), + ); + return { app: 'capmark', version: 1, exportadoEn: new Date().toISOString(), obras: detalle }; + } + + /** + * Restaura un backup. `reemplazar` borra las obras del backup antes de reinsertarlas + * (evita duplicar fuentes/progreso); las demás obras locales se conservan (merge). + */ + async importar(backup: Backup, reemplazar = true): Promise<{ obras: number }> { + if (backup?.app !== 'capmark' || backup.version !== 1 || !Array.isArray(backup.obras)) { + throw new Error('Archivo de backup no válido para CapMark.'); + } + for (const { obra, fuentes, progreso } of backup.obras) { + if (reemplazar) await this.repo.eliminarObra(obra.id); + await this.repo.guardarObra(obra); + await this.repo.guardarFuentes(obra.id, fuentes); + for (const p of progreso) await this.repo.agregarProgreso(p); + } + return { obras: backup.obras.length }; + } +} diff --git a/src/application/catalogo-service.test.ts b/src/application/catalogo-service.test.ts new file mode 100644 index 0000000..97a23d3 --- /dev/null +++ b/src/application/catalogo-service.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CatalogoService } from './catalogo-service'; +import { fakeIdGen, fakeReloj, MemoryRepositorio } from '@test/support'; + +function nuevo() { + const repo = new MemoryRepositorio(); + return { repo, svc: new CatalogoService(repo, fakeIdGen(), fakeReloj()) }; +} + +describe('CatalogoService', () => { + let repo: MemoryRepositorio; + let svc: CatalogoService; + beforeEach(() => ({ repo, svc } = nuevo())); + + it('crea y persiste una obra', async () => { + const o = await svc.crear({ titulo: 'Berserk', tipo: 'manga' }); + expect(await repo.obtenerObra(o.id)).toMatchObject({ titulo: 'Berserk', tipo: 'manga' }); + }); + + it('busca por alias, insensible a mayúsculas (RF-013)', async () => { + await svc.crear({ titulo: 'Solo Leveling', tipo: 'manhua', nombresAlternativos: ['Only I Level Up'] }); + await svc.crear({ titulo: 'Naruto', tipo: 'manga' }); + const r = await svc.buscar({ termino: 'only i level' }); + expect(r.map((o) => o.titulo)).toEqual(['Solo Leveling']); + }); + + it('combina filtros de estado y prioridad (RF-014)', async () => { + await svc.crear({ titulo: 'A', tipo: 'manga', estado: 'leyendo', prioridad: 'alta' }); + await svc.crear({ titulo: 'B', tipo: 'manga', estado: 'leyendo', prioridad: 'baja' }); + await svc.crear({ titulo: 'C', tipo: 'manga', estado: 'pausado', prioridad: 'alta' }); + const r = await svc.buscar({ estado: 'leyendo', prioridad: 'alta' }); + expect(r.map((o) => o.titulo)).toEqual(['A']); + }); + + it('ordena el catálogo por título', async () => { + await svc.crear({ titulo: 'Zeta', tipo: 'manga' }); + await svc.crear({ titulo: 'Alfa', tipo: 'manga' }); + expect((await svc.buscar()).map((o) => o.titulo)).toEqual(['Alfa', 'Zeta']); + }); + + it('elimina la obra y su rastro (RF-006)', async () => { + const o = await svc.crear({ titulo: 'X', tipo: 'manga' }); + await svc.eliminar(o.id); + expect(await repo.obtenerObra(o.id)).toBeUndefined(); + }); +}); diff --git a/src/application/catalogo-service.ts b/src/application/catalogo-service.ts new file mode 100644 index 0000000..966d231 --- /dev/null +++ b/src/application/catalogo-service.ts @@ -0,0 +1,49 @@ +import { coincideBusqueda, crearObra, editarObra, Obra } from '@domain/obra'; +import { EstadoObra, Prioridad } from '@domain/types'; +import { IdGen, NuevaObra, Reloj, Repositorio } from './ports'; + +export interface Filtro { + termino?: string; // RF-013 + tag?: string; // RF-014 + estado?: EstadoObra; // RF-014 + prioridad?: Prioridad; // RF-014 +} + +/** Casos de uso del catálogo (F-01, F-04). */ +export class CatalogoService { + constructor(private repo: Repositorio, private id: IdGen, private reloj: Reloj) {} + + async crear(input: NuevaObra): Promise { + const obra = crearObra(input, this.id.nuevo(), this.reloj.ahora()); + await this.repo.guardarObra(obra); + return obra; + } + + async editar(id: string, cambios: Partial): Promise { + const actual = await this.repo.obtenerObra(id); + if (!actual) throw new Error(`Obra no encontrada: ${id}`); + const obra = editarObra(actual, cambios, this.reloj.ahora()); + await this.repo.guardarObra(obra); + return obra; + } + + /** RF-006: elimina la obra y, en cascada, sus fuentes e historial. */ + async eliminar(id: string): Promise { + await this.repo.eliminarObra(id); + } + + async obtener(id: string): Promise { + return this.repo.obtenerObra(id); + } + + /** RF-013 + RF-014: búsqueda por título/alias y filtros combinables. */ + async buscar(filtro: Filtro = {}): Promise { + const todas = await this.repo.listarObras(); + return todas + .filter((o) => coincideBusqueda(o, filtro.termino ?? '')) + .filter((o) => (filtro.tag ? o.tags.some((t) => t.toLowerCase() === filtro.tag!.toLowerCase()) : true)) + .filter((o) => (filtro.estado ? o.estado === filtro.estado : true)) + .filter((o) => (filtro.prioridad ? o.prioridad === filtro.prioridad : true)) + .sort((a, b) => a.titulo.localeCompare(b.titulo, 'es')); + } +} diff --git a/src/application/fuente-service.test.ts b/src/application/fuente-service.test.ts new file mode 100644 index 0000000..99afc10 --- /dev/null +++ b/src/application/fuente-service.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { FuenteService } from './fuente-service'; +import { fakeIdGen, fakeVerifier, MemoryRepositorio } from '@test/support'; + +const OBRA = 'obra-1'; + +describe('FuenteService', () => { + let repo: MemoryRepositorio; + let svc: FuenteService; + beforeEach(() => { + repo = new MemoryRepositorio(); + svc = new FuenteService(repo, fakeIdGen(), fakeVerifier('caida')); + }); + + it('la primera fuente queda principal por defecto (RF-009)', async () => { + const fs = await svc.agregar(OBRA, { nombreEnSitio: 'A', url: 'https://a.com' }); + expect(fs[0].esPrincipal).toBe(true); + }); + + it('marcar principal deja exactamente una (RF-009)', async () => { + await svc.agregar(OBRA, { nombreEnSitio: 'A', url: 'https://a.com' }); + const fs = await svc.agregar(OBRA, { nombreEnSitio: 'B', url: 'https://b.com' }); + const marcadas = await svc.marcarPrincipal(OBRA, fs[1].id); + expect(marcadas.filter((f) => f.esPrincipal)).toHaveLength(1); + expect(marcadas.find((f) => f.esPrincipal)?.nombreEnSitio).toBe('B'); + }); + + it('eliminar la principal deja la obra sin principal (RF-010)', async () => { + const fs = await svc.agregar(OBRA, { nombreEnSitio: 'A', url: 'https://a.com' }); + const rest = await svc.eliminar(OBRA, fs[0].id); + expect(rest).toHaveLength(0); + }); + + it('verificar registra estado y fecha (RF-017)', async () => { + const fs = await svc.agregar(OBRA, { nombreEnSitio: 'A', url: 'https://a.com' }); + const [f] = await svc.verificar(OBRA, fs[0].id); + expect(f.estadoVerificacion).toBe('caida'); + expect(f.verificadaEn).toBeDefined(); + }); +}); diff --git a/src/application/fuente-service.ts b/src/application/fuente-service.ts new file mode 100644 index 0000000..2b9fc18 --- /dev/null +++ b/src/application/fuente-service.ts @@ -0,0 +1,47 @@ +import { crearFuente, Fuente, marcarPrincipal } from '@domain/fuente'; +import { IdGen, LinkVerifier, NuevaFuente, Repositorio } from './ports'; + +/** Casos de uso de fuentes por obra (F-02, F-06). */ +export class FuenteService { + constructor(private repo: Repositorio, private id: IdGen, private verifier: LinkVerifier) {} + + async listar(obraId: string): Promise { + return this.repo.listarFuentes(obraId); + } + + /** RF-008: agrega una fuente. Si es la primera, queda principal por defecto. */ + async agregar(obraId: string, input: NuevaFuente): Promise { + const fuentes = await this.repo.listarFuentes(obraId); + const nueva = crearFuente(obraId, { ...input, esPrincipal: input.esPrincipal ?? fuentes.length === 0 }, this.id.nuevo()); + let siguiente = [...fuentes, nueva]; + if (nueva.esPrincipal) siguiente = marcarPrincipal(siguiente, nueva.id); // RF-009: máx. una principal + await this.repo.guardarFuentes(obraId, siguiente); + return siguiente; + } + + /** RF-010: elimina una fuente; si era la principal, la obra queda sin principal. */ + async eliminar(obraId: string, fuenteId: string): Promise { + const fuentes = (await this.repo.listarFuentes(obraId)).filter((f) => f.id !== fuenteId); + await this.repo.guardarFuentes(obraId, fuentes); + return fuentes; + } + + async marcarPrincipal(obraId: string, fuenteId: string): Promise { + const fuentes = marcarPrincipal(await this.repo.listarFuentes(obraId), fuenteId); + await this.repo.guardarFuentes(obraId, fuentes); + return fuentes; + } + + /** RF-017 / RNF-008: verifica una fuente sin bloquear; persiste estado + fecha. */ + async verificar(obraId: string, fuenteId: string): Promise { + const fuentes = await this.repo.listarFuentes(obraId); + const fuente = fuentes.find((f) => f.id === fuenteId); + if (!fuente) throw new Error(`Fuente no encontrada: ${fuenteId}`); + const { estado, verificadaEn } = await this.verifier.verificar(fuente.url); + const siguiente = fuentes.map((f) => + f.id === fuenteId ? { ...f, estadoVerificacion: estado, verificadaEn } : f, + ); + await this.repo.guardarFuentes(obraId, siguiente); + return siguiente; + } +} diff --git a/src/application/ports.ts b/src/application/ports.ts new file mode 100644 index 0000000..e2ff7c7 --- /dev/null +++ b/src/application/ports.ts @@ -0,0 +1,66 @@ +// Puertos: interfaces que la capa de aplicación necesita. Los adaptadores concretos +// (SQLite/Dexie, Supabase, scraper HTTP...) viven en infraestructura. Así el backend +// de sincronización o la persistencia son sustituibles sin tocar la lógica (RNF-009). + +import { Fuente, NuevaFuente } from '@domain/fuente'; +import { NuevaObra, Obra } from '@domain/obra'; +import { NuevoProgreso, ProgresoEntry } from '@domain/progreso'; +import { EstadoFuente } from '@domain/types'; + +export interface IdGen { + nuevo(): string; +} + +export interface Reloj { + ahora(): Date; +} + +/** Persistencia de las tres agregados. Local-first: los adaptadores confirman en local + * antes de sincronizar (RNF-004, RNF-007). */ +export interface Repositorio { + // Obras + guardarObra(obra: Obra): Promise; + obtenerObra(id: string): Promise; + listarObras(): Promise; + eliminarObra(id: string): Promise; // borra en cascada fuentes + progreso (RF-006) + // Fuentes + guardarFuentes(obraId: string, fuentes: Fuente[]): Promise; + listarFuentes(obraId: string): Promise; + // Progreso + agregarProgreso(entry: ProgresoEntry): Promise; + listarProgreso(obraId: string): Promise; +} + +/** RF-017 / RNF-008: verifica del lado servidor si una URL responde (≤ 5 s, no bloqueante). */ +export interface LinkVerifier { + verificar(url: string): Promise<{ estado: EstadoFuente; verificadaEn: string }>; +} + +/** Módulo scraper opt-in (decisión del usuario): dado un HTML/URL de fuente, PROPONE + * el capítulo detectado. Nunca escribe; la app pide confirmación antes de guardar. */ +export interface PropuestaCapitulo { + capitulo: number; + etiqueta?: string; // texto crudo detectado, p. ej. "Capítulo 42" + confianza: 'alta' | 'media' | 'baja'; + fuenteUrl: string; +} + +export interface SourceScraper { + /** ¿Este adaptador sabe leer este dominio? */ + soporta(url: string): boolean; + /** Devuelve una propuesta de capítulo, o undefined si no logra detectarlo. */ + detectar(url: string): Promise; +} + +/** Sincronización opcional con un backend auto-instanciable. La app funciona sin él. */ +export interface SyncPort { + push(): Promise; + pull(): Promise; + /** ¿Hay un backend configurado? (barato, solo mira configuración). */ + disponible(): boolean; + /** ¿El backend responde AHORA? (red, con timeout). Nunca lanza: si falla, es `false` + * y la app sigue en local. Base del aviso "No se puede sincronizar" (RNF-004). */ + probarConexion(): Promise; +} + +export type { NuevaObra, NuevaFuente, NuevoProgreso }; diff --git a/src/application/progreso-service.test.ts b/src/application/progreso-service.test.ts new file mode 100644 index 0000000..24a142b --- /dev/null +++ b/src/application/progreso-service.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ProgresoService } from './progreso-service'; +import { fakeIdGen, fakeReloj, MemoryRepositorio } from '@test/support'; + +const OBRA = 'obra-1'; + +describe('ProgresoService', () => { + it('cada registro crea una entrada fechada y el historial va desc (RF-012)', async () => { + const svc = new ProgresoService(new MemoryRepositorio(), fakeIdGen(), fakeReloj()); + await svc.registrar(OBRA, { capitulo: 1 }); + await svc.registrar(OBRA, { capitulo: 2 }); + await svc.registrar(OBRA, { capitulo: 3 }); + const h = await svc.historial(OBRA); + expect(h.map((e) => e.capitulo)).toEqual([3, 2, 1]); + }); + + it('el punto actual es la entrada más reciente (RF-011)', async () => { + const svc = new ProgresoService(new MemoryRepositorio(), fakeIdGen(), fakeReloj()); + await svc.registrar(OBRA, { capitulo: 10 }); + await svc.registrar(OBRA, { capitulo: 10.5, punto: 'mitad' }); + const actual = await svc.actual(OBRA); + expect(actual).toMatchObject({ capitulo: 10.5, punto: 'mitad' }); + }); + + it('rechaza capítulos negativos (RF-011)', async () => { + const svc = new ProgresoService(new MemoryRepositorio(), fakeIdGen(), fakeReloj()); + await expect(svc.registrar(OBRA, { capitulo: -1 })).rejects.toThrow(); + }); +}); diff --git a/src/application/progreso-service.ts b/src/application/progreso-service.ts new file mode 100644 index 0000000..08a6b5f --- /dev/null +++ b/src/application/progreso-service.ts @@ -0,0 +1,24 @@ +import { ProgresoEntry, puntoActual, registrarProgreso } from '@domain/progreso'; +import { IdGen, NuevoProgreso, Reloj, Repositorio } from './ports'; + +/** Casos de uso de progreso e historial (F-03). */ +export class ProgresoService { + constructor(private repo: Repositorio, private id: IdGen, private reloj: Reloj) {} + + /** RF-011/012: registra el punto actual; cada actualización crea una entrada fechada. */ + async registrar(obraId: string, input: NuevoProgreso): Promise { + const entry = registrarProgreso(obraId, input, this.id.nuevo(), this.reloj.ahora()); + await this.repo.agregarProgreso(entry); + return entry; + } + + /** RF-012: historial en orden cronológico descendente (lo más reciente primero). */ + async historial(obraId: string): Promise { + const h = await this.repo.listarProgreso(obraId); + return [...h].sort((a, b) => b.registradoEn.localeCompare(a.registradoEn)); + } + + async actual(obraId: string): Promise { + return puntoActual(await this.repo.listarProgreso(obraId)); + } +} diff --git a/src/application/scraper-service.ts b/src/application/scraper-service.ts new file mode 100644 index 0000000..cf9520d --- /dev/null +++ b/src/application/scraper-service.ts @@ -0,0 +1,30 @@ +import { PropuestaCapitulo, SourceScraper } from './ports'; + +/** + * Módulo scraper OPT-IN (decisión del usuario). Filosofía semi-asistida (S2): + * NUNCA escribe en la data local. Solo PROPONE un capítulo detectado en una fuente, + * bajo demanda; la UI muestra la propuesta y el usuario confirma antes de registrar + * el progreso (vía ProgresoService). + */ +export class ScraperService { + constructor(private adaptadores: SourceScraper[]) {} + + /** ¿Hay algún adaptador capaz de leer esta URL? */ + soportado(url: string): boolean { + return this.adaptadores.some((a) => a.soporta(url)); + } + + /** Devuelve la propuesta del primer adaptador que soporte la URL y logre detectar. */ + async detectar(url: string): Promise { + for (const a of this.adaptadores) { + if (!a.soporta(url)) continue; + try { + const propuesta = await a.detectar(url); + if (propuesta) return propuesta; + } catch { + // adaptador frágil ante cambios del sitio: seguimos con el siguiente + } + } + return undefined; + } +} diff --git a/src/domain/fuente.test.ts b/src/domain/fuente.test.ts new file mode 100644 index 0000000..a28fd7a --- /dev/null +++ b/src/domain/fuente.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { crearFuente, fuentePrincipal, marcarPrincipal } from './fuente'; +import { DomainError } from './types'; + +describe('crearFuente (RF-008)', () => { + it('rechaza URL no http(s)', () => { + expect(() => crearFuente('o1', { nombreEnSitio: 'X', url: 'ftp://x' }, 'f1')).toThrow(DomainError); + }); + it('usa el hostname como nombre si no se da uno', () => { + const f = crearFuente('o1', { nombreEnSitio: '', url: 'https://mangadex.org/title/1' }, 'f1'); + expect(f.nombreEnSitio).toBe('mangadex.org'); + }); +}); + +describe('marcarPrincipal (RF-009)', () => { + it('deja exactamente una principal', () => { + const fs = [ + crearFuente('o1', { nombreEnSitio: 'A', url: 'https://a.com' }, 'f1'), + crearFuente('o1', { nombreEnSitio: 'B', url: 'https://b.com' }, 'f2'), + ]; + const out = marcarPrincipal(fs, 'f2'); + expect(out.filter((f) => f.esPrincipal).map((f) => f.id)).toEqual(['f2']); + expect(fuentePrincipal(out)?.id).toBe('f2'); + }); +}); diff --git a/src/domain/fuente.ts b/src/domain/fuente.ts new file mode 100644 index 0000000..f338332 --- /dev/null +++ b/src/domain/fuente.ts @@ -0,0 +1,59 @@ +import { DomainError, EstadoFuente } from './types'; + +export interface Fuente { + id: string; + obraId: string; + nombreEnSitio: string; + url: string; + esPrincipal: boolean; + estadoVerificacion: EstadoFuente; + verificadaEn?: string; // ISO 8601 +} + +export interface NuevaFuente { + nombreEnSitio: string; + url: string; + esPrincipal?: boolean; +} + +function validarUrl(raw: string): string { + const url = raw.trim(); + if (!url) throw new DomainError('La fuente requiere una URL (RF-008).'); + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new DomainError('La URL de la fuente debe ser http(s) (RF-008).'); + } + } catch { + throw new DomainError(`URL inválida: ${url} (RF-008).`); + } + return url; +} + +/** RF-008: crea una fuente con nombre-en-el-sitio y URL válida. */ +export function crearFuente(obraId: string, input: NuevaFuente, id: string): Fuente { + return { + id, + obraId, + nombreEnSitio: input.nombreEnSitio.trim() || new URL(validarUrl(input.url)).hostname, + url: validarUrl(input.url), + esPrincipal: input.esPrincipal ?? false, + estadoVerificacion: 'sin_verificar', + }; +} + +/** + * RF-009: garantiza como máximo una fuente principal. + * Devuelve la lista con `principalId` marcada como principal y el resto en false. + */ +export function marcarPrincipal(fuentes: Fuente[], principalId: string): Fuente[] { + if (!fuentes.some((f) => f.id === principalId)) { + throw new DomainError('La fuente a marcar como principal no pertenece a la obra (RF-009).'); + } + return fuentes.map((f) => ({ ...f, esPrincipal: f.id === principalId })); +} + +/** RF-009: la fuente ofrecida por defecto para abrir. */ +export function fuentePrincipal(fuentes: Fuente[]): Fuente | undefined { + return fuentes.find((f) => f.esPrincipal) ?? fuentes[0]; +} diff --git a/src/domain/index.ts b/src/domain/index.ts new file mode 100644 index 0000000..fd337b6 --- /dev/null +++ b/src/domain/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './obra'; +export * from './fuente'; +export * from './progreso'; diff --git a/src/domain/obra.test.ts b/src/domain/obra.test.ts new file mode 100644 index 0000000..d29f0a3 --- /dev/null +++ b/src/domain/obra.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { coincideBusqueda, crearObra, editarObra } from './obra'; +import { DomainError } from './types'; + +describe('crearObra', () => { + it('exige título y tipo (RF-001)', () => { + expect(() => crearObra({ titulo: ' ', tipo: 'manga' }, 'id')).toThrow(DomainError); + // @ts-expect-error tipo inválido + expect(() => crearObra({ titulo: 'X', tipo: 'comic' }, 'id')).toThrow(DomainError); + }); + + it('aplica prioridad media por defecto (RF-005)', () => { + expect(crearObra({ titulo: 'X', tipo: 'manga' }, 'id').prioridad).toBe('media'); + }); + + it('deduplica tags sin distinguir mayúsculas (RF-003)', () => { + const o = crearObra({ titulo: 'X', tipo: 'manga', tags: ['Acción', 'acción', ' acción '] }, 'id'); + expect(o.tags).toEqual(['Acción']); + }); +}); + +describe('editarObra', () => { + it('conserva creada_en y refresca actualizada_en', () => { + const t0 = new Date('2020-01-01T00:00:00Z'); + const o = crearObra({ titulo: 'X', tipo: 'manga' }, 'id', t0); + const t1 = new Date('2021-06-06T00:00:00Z'); + const e = editarObra(o, { estado: 'leyendo' }, t1); + expect(e.creadaEn).toBe(o.creadaEn); + expect(e.actualizadaEn).toBe(t1.toISOString()); + expect(e.estado).toBe('leyendo'); + }); +}); + +describe('coincideBusqueda (RF-013)', () => { + const o = crearObra({ titulo: 'Solo Leveling', tipo: 'manhua', nombresAlternativos: ['나 혼자만 레벨업'] }, 'id'); + it('encuentra por título, insensible a mayúsculas', () => { + expect(coincideBusqueda(o, 'solo')).toBe(true); + }); + it('encuentra por alias', () => { + expect(coincideBusqueda(o, '레벨업')).toBe(true); + }); + it('no coincide con término ajeno', () => { + expect(coincideBusqueda(o, 'naruto')).toBe(false); + }); +}); diff --git a/src/domain/obra.ts b/src/domain/obra.ts new file mode 100644 index 0000000..db1c169 --- /dev/null +++ b/src/domain/obra.ts @@ -0,0 +1,99 @@ +import { + DomainError, + ESTADOS_OBRA, + EstadoObra, + Prioridad, + PRIORIDADES, + TIPOS_OBRA, + TipoObra, +} from './types'; + +export interface Obra { + id: string; + titulo: string; + tipo: TipoObra; + nombresAlternativos: string[]; + tags: string[]; + estado: EstadoObra; + prioridad: Prioridad; + notas?: string; + creadaEn: string; // ISO 8601 + actualizadaEn: string; // ISO 8601 +} + +export interface NuevaObra { + titulo: string; + tipo: TipoObra; + nombresAlternativos?: string[]; + tags?: string[]; + estado?: EstadoObra; + prioridad?: Prioridad; + notas?: string; +} + +const norm = (s: string) => s.trim(); +const dedup = (xs: string[]) => { + const seen = new Set(); + const out: string[] = []; + for (const x of xs.map(norm).filter(Boolean)) { + const key = x.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + out.push(x); + } + } + return out; +}; + +/** RF-001/002/003/004/005: crea una obra validando sus invariantes. */ +export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obra { + const titulo = norm(input.titulo); + if (!titulo) throw new DomainError('La obra requiere un título principal (RF-001).'); + if (!TIPOS_OBRA.includes(input.tipo)) throw new DomainError(`Tipo inválido: ${input.tipo} (RF-001).`); + + const estado = input.estado ?? 'pendiente'; + if (!ESTADOS_OBRA.includes(estado)) throw new DomainError(`Estado inválido: ${estado} (RF-004).`); + + const prioridad = input.prioridad ?? 'media'; // RF-005: por defecto media + if (!PRIORIDADES.includes(prioridad)) throw new DomainError(`Prioridad inválida: ${prioridad} (RF-005).`); + + const iso = ahora.toISOString(); + return { + id, + titulo, + tipo: input.tipo, + nombresAlternativos: dedup(input.nombresAlternativos ?? []), + tags: dedup(input.tags ?? []), // RF-003: sin duplicados dentro de la obra + estado, + prioridad, + notas: input.notas?.trim() || undefined, + creadaEn: iso, + actualizadaEn: iso, + }; +} + +/** Aplica cambios parciales revalidando invariantes y refrescando `actualizadaEn`. */ +export function editarObra(obra: Obra, cambios: Partial, ahora = new Date()): Obra { + const merged = crearObra( + { + titulo: cambios.titulo ?? obra.titulo, + tipo: cambios.tipo ?? obra.tipo, + nombresAlternativos: cambios.nombresAlternativos ?? obra.nombresAlternativos, + tags: cambios.tags ?? obra.tags, + estado: cambios.estado ?? obra.estado, + prioridad: cambios.prioridad ?? obra.prioridad, + notas: cambios.notas ?? obra.notas, + }, + obra.id, + ahora, + ); + return { ...merged, creadaEn: obra.creadaEn }; +} + +/** RF-013: ¿coincide la obra con el término, por título o alias (case-insensitive)? */ +export function coincideBusqueda(obra: Obra, termino: string): boolean { + const t = termino.trim().toLowerCase(); + if (!t) return true; + if (obra.titulo.toLowerCase().includes(t)) return true; + return obra.nombresAlternativos.some((n) => n.toLowerCase().includes(t)); +} diff --git a/src/domain/progreso.ts b/src/domain/progreso.ts new file mode 100644 index 0000000..1a402b5 --- /dev/null +++ b/src/domain/progreso.ts @@ -0,0 +1,39 @@ +import { DomainError } from './types'; + +export interface ProgresoEntry { + id: string; + obraId: string; + capitulo: number; // admite no enteros: 10.5 (RF-011) + punto?: string; // página o punto opcional + registradoEn: string; // ISO 8601 (RF-012) +} + +export interface NuevoProgreso { + capitulo: number; + punto?: string; +} + +/** RF-011/012: registra una entrada de progreso validando el capítulo. */ +export function registrarProgreso( + obraId: string, + input: NuevoProgreso, + id: string, + ahora = new Date(), +): ProgresoEntry { + if (!Number.isFinite(input.capitulo) || input.capitulo < 0) { + throw new DomainError(`Capítulo inválido: ${input.capitulo} (RF-011).`); + } + return { + id, + obraId, + capitulo: input.capitulo, + punto: input.punto?.trim() || undefined, + registradoEn: ahora.toISOString(), + }; +} + +/** RF-011/012: entrada más reciente = punto actual + fecha de "última lectura". */ +export function puntoActual(historial: ProgresoEntry[]): ProgresoEntry | undefined { + if (historial.length === 0) return undefined; + return [...historial].sort((a, b) => b.registradoEn.localeCompare(a.registradoEn))[0]; +} diff --git a/src/domain/types.ts b/src/domain/types.ts new file mode 100644 index 0000000..92cbfb2 --- /dev/null +++ b/src/domain/types.ts @@ -0,0 +1,20 @@ +// Capa de dominio: vocabulario del negocio. Sin dependencias de infraestructura ni UI. + +export type TipoObra = 'manga' | 'manhua' | 'novela'; +export const TIPOS_OBRA: TipoObra[] = ['manga', 'manhua', 'novela']; + +export type EstadoObra = 'pendiente' | 'leyendo' | 'pausado' | 'abandonado' | 'completado'; +export const ESTADOS_OBRA: EstadoObra[] = ['pendiente', 'leyendo', 'pausado', 'abandonado', 'completado']; + +export type Prioridad = 'alta' | 'media' | 'baja'; +export const PRIORIDADES: Prioridad[] = ['alta', 'media', 'baja']; + +export type EstadoFuente = 'activa' | 'caida' | 'sin_verificar'; + +/** Error de violación de una invariante de dominio. */ +export class DomainError extends Error { + constructor(message: string) { + super(message); + this.name = 'DomainError'; + } +} diff --git a/src/infrastructure/container.ts b/src/infrastructure/container.ts new file mode 100644 index 0000000..1c7ec15 --- /dev/null +++ b/src/infrastructure/container.ts @@ -0,0 +1,28 @@ +// Composition root: única capa que conoce a los adaptadores concretos y los inyecta en +// los servicios de aplicación. Cambiar de backend = cambiar aquí, sin tocar dominio/UI. + +import { BackupService } from '@application/backup-service'; +import { CatalogoService } from '@application/catalogo-service'; +import { FuenteService } from '@application/fuente-service'; +import { ProgresoService } from '@application/progreso-service'; +import { ScraperService } from '@application/scraper-service'; +import { db } from './persistence/db'; +import { DexieRepositorio } from './persistence/dexie-repositorio'; +import { GenericScraper } from './scraper/generic-scraper'; +import { SupabaseSync } from './sync/supabase-sync'; +import { idGen, reloj } from './system'; +import { HttpLinkVerifier } from './verify/link-verifier'; + +const repo = new DexieRepositorio(db); +const verifier = new HttpLinkVerifier(); + +export const container = { + catalogo: new CatalogoService(repo, idGen, reloj), + fuentes: new FuenteService(repo, idGen, verifier), + progreso: new ProgresoService(repo, idGen, reloj), + scraper: new ScraperService([new GenericScraper()]), + backup: new BackupService(repo), + sync: new SupabaseSync(), +}; + +export type Container = typeof container; diff --git a/src/infrastructure/persistence/db.ts b/src/infrastructure/persistence/db.ts new file mode 100644 index 0000000..afc975a --- /dev/null +++ b/src/infrastructure/persistence/db.ts @@ -0,0 +1,25 @@ +import Dexie, { Table } from 'dexie'; +import { Fuente } from '@domain/fuente'; +import { Obra } from '@domain/obra'; +import { ProgresoEntry } from '@domain/progreso'; + +/** + * Persistencia local-first sobre IndexedDB (web) vía Dexie. En build nativo (Capacitor) + * este adaptador se sustituye por SQLite tras el mismo puerto Repositorio (RNF-004/007). + */ +export class CapMarkDB extends Dexie { + obras!: Table; + fuentes!: Table; + progreso!: Table; + + constructor() { + super('capmark'); + this.version(1).stores({ + obras: 'id, titulo, estado, prioridad, actualizadaEn', + fuentes: 'id, obraId, esPrincipal', + progreso: 'id, obraId, registradoEn', + }); + } +} + +export const db = new CapMarkDB(); diff --git a/src/infrastructure/persistence/dexie-repositorio.ts b/src/infrastructure/persistence/dexie-repositorio.ts new file mode 100644 index 0000000..4496466 --- /dev/null +++ b/src/infrastructure/persistence/dexie-repositorio.ts @@ -0,0 +1,50 @@ +import { Repositorio } from '@application/ports'; +import { Fuente } from '@domain/fuente'; +import { Obra } from '@domain/obra'; +import { ProgresoEntry } from '@domain/progreso'; +import { CapMarkDB } from './db'; + +/** Adaptador de Repositorio sobre Dexie/IndexedDB. Confirma en local antes de sincronizar. */ +export class DexieRepositorio implements Repositorio { + constructor(private db: CapMarkDB) {} + + async guardarObra(obra: Obra): Promise { + await this.db.obras.put(obra); + } + + async obtenerObra(id: string): Promise { + return this.db.obras.get(id); + } + + async listarObras(): Promise { + return this.db.obras.toArray(); + } + + async eliminarObra(id: string): Promise { + // RF-006: borrado en cascada de fuentes e historial. + await this.db.transaction('rw', this.db.obras, this.db.fuentes, this.db.progreso, async () => { + await this.db.obras.delete(id); + await this.db.fuentes.where('obraId').equals(id).delete(); + await this.db.progreso.where('obraId').equals(id).delete(); + }); + } + + async guardarFuentes(obraId: string, fuentes: Fuente[]): Promise { + await this.db.transaction('rw', this.db.fuentes, async () => { + await this.db.fuentes.where('obraId').equals(obraId).delete(); + if (fuentes.length) await this.db.fuentes.bulkPut(fuentes); + }); + } + + async listarFuentes(obraId: string): Promise { + return this.db.fuentes.where('obraId').equals(obraId).toArray(); + } + + async agregarProgreso(entry: ProgresoEntry): Promise { + await this.db.progreso.put(entry); + } + + async listarProgreso(obraId: string): Promise { + return this.db.progreso.where('obraId').equals(obraId).toArray(); + } +} diff --git a/src/infrastructure/persistence/memory-repositorio.ts b/src/infrastructure/persistence/memory-repositorio.ts new file mode 100644 index 0000000..6e044f8 --- /dev/null +++ b/src/infrastructure/persistence/memory-repositorio.ts @@ -0,0 +1,43 @@ +import { Repositorio } from '@application/ports'; +import { Fuente } from '@domain/fuente'; +import { Obra } from '@domain/obra'; +import { ProgresoEntry } from '@domain/progreso'; + +/** + * Repositorio en memoria. Útil para tests de la capa de aplicación y como fallback + * (p. ej. SSR o entornos sin IndexedDB). Implementa el mismo puerto que Dexie/SQLite. + */ +export class MemoryRepositorio implements Repositorio { + private obras = new Map(); + private fuentes = new Map(); + private progreso = new Map(); + + async guardarObra(obra: Obra): Promise { + this.obras.set(obra.id, obra); + } + async obtenerObra(id: string): Promise { + return this.obras.get(id); + } + async listarObras(): Promise { + return [...this.obras.values()]; + } + async eliminarObra(id: string): Promise { + this.obras.delete(id); + this.fuentes.delete(id); + this.progreso.delete(id); + } + async guardarFuentes(obraId: string, fuentes: Fuente[]): Promise { + this.fuentes.set(obraId, [...fuentes]); + } + async listarFuentes(obraId: string): Promise { + return [...(this.fuentes.get(obraId) ?? [])]; + } + async agregarProgreso(entry: ProgresoEntry): Promise { + const list = this.progreso.get(entry.obraId) ?? []; + list.push(entry); + this.progreso.set(entry.obraId, list); + } + async listarProgreso(obraId: string): Promise { + return [...(this.progreso.get(obraId) ?? [])]; + } +} diff --git a/src/infrastructure/scraper/chapter-extractor.test.ts b/src/infrastructure/scraper/chapter-extractor.test.ts new file mode 100644 index 0000000..d4ebd85 --- /dev/null +++ b/src/infrastructure/scraper/chapter-extractor.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { extraerCapitulo } from './chapter-extractor'; + +describe('extraerCapitulo (scraper opt-in)', () => { + it('toma el capítulo más alto mencionado', () => { + const html = 'Capítulo 10Capítulo 42Cap. 41'; + const p = extraerCapitulo(html, 'https://x.com'); + expect(p?.capitulo).toBe(42); + }); + + it('soporta capítulos con decimales', () => { + const p = extraerCapitulo('Chapter 179.5 disponible', 'https://x.com'); + expect(p?.capitulo).toBe(179.5); + }); + + it('devuelve undefined si no hay nada que detectar', () => { + expect(extraerCapitulo('

sin números de capítulo

', 'https://x.com')).toBeUndefined(); + }); +}); diff --git a/src/infrastructure/scraper/chapter-extractor.ts b/src/infrastructure/scraper/chapter-extractor.ts new file mode 100644 index 0000000..9403932 --- /dev/null +++ b/src/infrastructure/scraper/chapter-extractor.ts @@ -0,0 +1,35 @@ +import { PropuestaCapitulo } from '@application/ports'; + +/** + * Extractor de capítulo PURO (sin red): dado un HTML, intenta encontrar el número de + * capítulo más alto mencionado. Se mantiene simple y testeable a propósito; es la pieza + * frágil ante cambios de los sitios, por eso el resultado se PROPONE, no se guarda. + */ +const PATRONES: RegExp[] = [ + /cap[íi]tulo\s*#?\s*(\d+(?:\.\d+)?)/gi, + /\bcap\.?\s*(\d+(?:\.\d+)?)/gi, + /\bchapter\s*#?\s*(\d+(?:\.\d+)?)/gi, + /\bch\.?\s*(\d+(?:\.\d+)?)/gi, +]; + +export function extraerCapitulo(html: string, fuenteUrl: string): PropuestaCapitulo | undefined { + const encontrados: { valor: number; etiqueta: string; patron: number }[] = []; + PATRONES.forEach((re, patronIdx) => { + for (const m of html.matchAll(re)) { + const valor = Number.parseFloat(m[1]); + if (Number.isFinite(valor)) encontrados.push({ valor, etiqueta: m[0].trim(), patron: patronIdx }); + } + }); + if (encontrados.length === 0) return undefined; + + // Tomamos el capítulo más alto (normalmente el último publicado / visible). + encontrados.sort((a, b) => b.valor - a.valor); + const mejor = encontrados[0]; + + // Confianza: patrón explícito "capítulo/chapter" > abreviaturas; más coincidencias = más confianza. + const distintos = new Set(encontrados.map((e) => e.valor)).size; + const confianza: PropuestaCapitulo['confianza'] = + mejor.patron <= 1 && distintos > 2 ? 'alta' : distintos > 1 ? 'media' : 'baja'; + + return { capitulo: mejor.valor, etiqueta: mejor.etiqueta, confianza, fuenteUrl }; +} diff --git a/src/infrastructure/scraper/generic-scraper.ts b/src/infrastructure/scraper/generic-scraper.ts new file mode 100644 index 0000000..96345ee --- /dev/null +++ b/src/infrastructure/scraper/generic-scraper.ts @@ -0,0 +1,25 @@ +import { PropuestaCapitulo, SourceScraper } from '@application/ports'; +import { extraerCapitulo } from './chapter-extractor'; + +/** + * Adaptador scraper genérico. Descarga el HTML de la fuente y delega en el extractor puro. + * En navegador, fetch cross-origin choca con CORS: si `VITE_SCRAPER_PROXY` está configurado + * (Edge Function / worker del backend auto-instanciable), la descarga se enruta por ahí. + * + * Es opt-in y semi-asistido: solo se invoca a petición del usuario y su salida se PROPONE. + */ +export class GenericScraper implements SourceScraper { + constructor(private proxy = import.meta.env.VITE_SCRAPER_PROXY as string | undefined) {} + + soporta(url: string): boolean { + return /^https?:\/\//i.test(url); + } + + async detectar(url: string): Promise { + const target = this.proxy ? `${this.proxy}?url=${encodeURIComponent(url)}` : url; + const res = await fetch(target, { headers: { accept: 'text/html' } }); + if (!res.ok) return undefined; + const html = await res.text(); + return extraerCapitulo(html, url); + } +} diff --git a/src/infrastructure/sync/supabase-sync.ts b/src/infrastructure/sync/supabase-sync.ts new file mode 100644 index 0000000..a95b9e5 --- /dev/null +++ b/src/infrastructure/sync/supabase-sync.ts @@ -0,0 +1,47 @@ +import { SyncPort } from '@application/ports'; + +/** + * Adaptador de sincronización opcional contra el backend auto-instanciable (Supabase + * self-hosted). La app es local-first: si no hay backend configurado, `disponible()` es + * false y todo sigue funcionando en local. Estrategia de conflictos: última escritura gana. + * + * Stub deliberado: el motor de sync completo (outbox + Realtime) es la Fase 3 del plan. + */ +export class SupabaseSync implements SyncPort { + private url = import.meta.env.VITE_SUPABASE_URL as string | undefined; + private key = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; + + disponible(): boolean { + return Boolean(this.url && this.key); + } + + /** + * Sondea si el backend responde, con timeout corto. Usa `no-cors` para que un servidor + * vivo cuente como alcanzable aunque no mande cabeceras CORS (solo nos importa "responde + * o no"). A prueba de fallos: cualquier error de red o timeout devuelve `false`; nunca + * lanza, así la app nunca se rompe por estar sin conexión (RNF-004). + */ + async probarConexion(): Promise { + if (!this.disponible()) return false; + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 3000); + try { + await fetch(this.url as string, { mode: 'no-cors', signal: ctrl.signal }); + return true; + } catch { + return false; + } finally { + clearTimeout(timeout); + } + } + + async push(): Promise { + if (!this.disponible()) return; + // TODO(Fase 3): subir outbox de cambios locales; LWW por `actualizadaEn`. + } + + async pull(): Promise { + if (!this.disponible()) return; + // TODO(Fase 3): traer cambios remotos vía Realtime/REST y fusionar por LWW. + } +} diff --git a/src/infrastructure/system.ts b/src/infrastructure/system.ts new file mode 100644 index 0000000..0f4db3a --- /dev/null +++ b/src/infrastructure/system.ts @@ -0,0 +1,12 @@ +import { IdGen, Reloj } from '@application/ports'; + +export const idGen: IdGen = { + nuevo: () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, +}; + +export const reloj: Reloj = { + ahora: () => new Date(), +}; diff --git a/src/infrastructure/verify/link-verifier.ts b/src/infrastructure/verify/link-verifier.ts new file mode 100644 index 0000000..81a797e --- /dev/null +++ b/src/infrastructure/verify/link-verifier.ts @@ -0,0 +1,38 @@ +import { LinkVerifier } from '@application/ports'; +import { EstadoFuente } from '@domain/types'; + +const TIMEOUT_MS = 5000; // RNF-008: ≤ 5 s por fuente + +/** + * RF-017 / RNF-008. Verificación del lado servidor cuando hay backend auto-instanciable: + * si `VITE_VERIFY_URL` apunta a la Edge Function, se delega ahí (evita CORS). Sin backend, + * hace un intento best-effort desde el cliente (modo no-cors: si resuelve, se asume activa). + */ +export class HttpLinkVerifier implements LinkVerifier { + constructor(private edgeFnUrl = import.meta.env.VITE_VERIFY_URL as string | undefined) {} + + async verificar(url: string): Promise<{ estado: EstadoFuente; verificadaEn: string }> { + const verificadaEn = new Date().toISOString(); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + if (this.edgeFnUrl) { + const res = await fetch(this.edgeFnUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url }), + signal: ctrl.signal, + }); + const data = (await res.json()) as { ok: boolean }; + return { estado: data.ok ? 'activa' : 'caida', verificadaEn }; + } + // Fallback sin backend: no-cors da respuesta opaca; si no lanza, la tomamos como activa. + await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal }); + return { estado: 'activa', verificadaEn }; + } catch { + return { estado: 'caida', verificadaEn }; + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..12cafee --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { setupIonicReact } from '@ionic/react'; +import App from '@ui/App'; + +/* Estilos base de Ionic */ +import '@ionic/react/css/core.css'; +import '@ionic/react/css/normalize.css'; +import '@ionic/react/css/structure.css'; +import '@ionic/react/css/typography.css'; +import '@ionic/react/css/padding.css'; +import '@ionic/react/css/flex-utils.css'; +/* Modo oscuro automático según el sistema */ +import '@ionic/react/css/palettes/dark.system.css'; +import '@ui/theme.css'; + +setupIonicReact(); + +const container = document.getElementById('root'); +createRoot(container!).render( + + + , +); diff --git a/src/test/support.ts b/src/test/support.ts new file mode 100644 index 0000000..49ee59d --- /dev/null +++ b/src/test/support.ts @@ -0,0 +1,30 @@ +import { MemoryRepositorio } from '@infrastructure/persistence/memory-repositorio'; +import { IdGen, LinkVerifier, Reloj } from '@application/ports'; +import { EstadoFuente } from '@domain/types'; + +/** IdGen determinista para tests: id-1, id-2, … */ +export function fakeIdGen(): IdGen { + let n = 0; + return { nuevo: () => `id-${++n}` }; +} + +/** Reloj que avanza 1 s en cada llamada, para historiales ordenables. */ +export function fakeReloj(desde = new Date('2024-01-01T00:00:00Z')): Reloj { + let t = desde.getTime(); + return { + ahora: () => { + const now = new Date(t); + t += 1000; + return now; + }, + }; +} + +/** LinkVerifier que devuelve un estado fijo, sin red. */ +export function fakeVerifier(estado: EstadoFuente = 'activa'): LinkVerifier { + return { + verificar: async () => ({ estado, verificadaEn: new Date('2024-06-01T00:00:00Z').toISOString() }), + }; +} + +export { MemoryRepositorio }; diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 0000000..ce1366e --- /dev/null +++ b/src/ui/App.tsx @@ -0,0 +1,30 @@ +import { lazy, Suspense } from 'react'; +import { IonApp, IonRouterOutlet, IonSpinner } from '@ionic/react'; +import { IonReactRouter } from '@ionic/react-router'; +import { Redirect, Route } from 'react-router-dom'; + +// Rutas con carga diferida: cada página es su propio chunk (bundle inicial más liviano). +const CatalogoPage = lazy(() => import('@ui/pages/CatalogoPage')); +const ObraDetallePage = lazy(() => import('@ui/pages/ObraDetallePage')); + +const Cargando = () => ( +
+); + +export default function App() { + return ( + + + }> + + + + + + + + + + + ); +} diff --git a/src/ui/components/ObraFormModal.tsx b/src/ui/components/ObraFormModal.tsx new file mode 100644 index 0000000..2837e68 --- /dev/null +++ b/src/ui/components/ObraFormModal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { + IonButton, IonButtons, IonContent, IonHeader, IonInput, IonItem, IonLabel, IonModal, + IonSelect, IonSelectOption, IonTextarea, IonTitle, IonToolbar, +} from '@ionic/react'; +import { NuevaObra } from '@application/ports'; +import { Obra } from '@domain/obra'; +import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA } from '@domain/types'; + +interface Props { + isOpen: boolean; + obra?: Obra; // si viene, es edición + onClose: () => void; + onSave: (input: NuevaObra) => Promise; +} + +const vacia: NuevaObra = { titulo: '', tipo: 'manga', estado: 'pendiente', prioridad: 'media' }; + +export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) { + const [form, setForm] = useState(vacia); + const [alias, setAlias] = useState(''); + const [tags, setTags] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + if (obra) { + setForm({ titulo: obra.titulo, tipo: obra.tipo, estado: obra.estado, prioridad: obra.prioridad, notas: obra.notas }); + setAlias(obra.nombresAlternativos.join(', ')); + setTags(obra.tags.join(', ')); + } else { + setForm(vacia); setAlias(''); setTags(''); + } + setError(null); + }, [obra, isOpen]); + + const split = (s: string) => s.split(',').map((x) => x.trim()).filter(Boolean); + + const guardar = async () => { + try { + await onSave({ ...form, nombresAlternativos: split(alias), tags: split(tags) }); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Error al guardar'); + } + }; + + return ( + + + + {obra ? 'Editar obra' : 'Nueva obra'} + + Cancelar + + + + + + Título principal * + setForm({ ...form, titulo: e.detail.value ?? '' })} placeholder="Ej. Solo Leveling" /> + + + Tipo * + setForm({ ...form, tipo: e.detail.value })}> + {TIPOS_OBRA.map((t) => {t})} + + + + Estado + setForm({ ...form, estado: e.detail.value })}> + {ESTADOS_OBRA.map((s) => {s})} + + + + Prioridad + setForm({ ...form, prioridad: e.detail.value })}> + {PRIORIDADES.map((p) => {p})} + + + + Nombres alternativos (coma) + setAlias(e.detail.value ?? '')} placeholder="나 혼자만 레벨업, 我独自升级" /> + + + Tags (coma) + setTags(e.detail.value ?? '')} placeholder="acción, fantasía" /> + + + Notas + setForm({ ...form, notas: e.detail.value ?? '' })} autoGrow /> + + + {error &&

{error}

} + Guardar +
+
+ ); +} diff --git a/src/ui/format.ts b/src/ui/format.ts new file mode 100644 index 0000000..f9017f8 --- /dev/null +++ b/src/ui/format.ts @@ -0,0 +1,25 @@ +import { EstadoObra, Prioridad } from '@domain/types'; + +export const colorEstado: Record = { + pendiente: 'medium', + leyendo: 'success', + pausado: 'warning', + abandonado: 'danger', + completado: 'primary', +}; + +export const colorPrioridad: Record = { + alta: 'danger', + media: 'warning', + baja: 'medium', +}; + +export function fechaCorta(iso?: string): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('es', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +export function capFmt(n?: number): string { + if (n === undefined) return '—'; + return Number.isInteger(n) ? String(n) : n.toFixed(1); +} diff --git a/src/ui/pages/CatalogoPage.tsx b/src/ui/pages/CatalogoPage.tsx new file mode 100644 index 0000000..3201538 --- /dev/null +++ b/src/ui/pages/CatalogoPage.tsx @@ -0,0 +1,211 @@ +import { useCallback, useState } from 'react'; +import { + IonBadge, IonButton, IonButtons, IonChip, IonContent, IonFab, IonFabButton, IonHeader, + IonIcon, IonItem, IonLabel, IonList, IonNote, IonPage, IonSearchbar, IonSelect, + IonSelectOption, IonTitle, IonToolbar, useIonViewWillEnter, +} from '@ionic/react'; +import { add, cloudOfflineOutline, cloudDoneOutline, downloadOutline, folderOpenOutline } from 'ionicons/icons'; +import { useRef } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Filtro } from '@application/catalogo-service'; +import { NuevaObra } from '@application/ports'; +import { Obra } from '@domain/obra'; +import { ESTADOS_OBRA, PRIORIDADES } from '@domain/types'; +import { container } from '@infrastructure/container'; +import ObraFormModal from '@ui/components/ObraFormModal'; +import { capFmt, colorEstado, colorPrioridad, fechaCorta } from '@ui/format'; +import { sembrarDemo } from '@ui/seed'; + +interface Fila { obra: Obra; capitulo?: number; ultima?: string; } + +// Estado de sincronización de cara al usuario. La app siempre funciona en local (RNF-004); +// esto solo informa si además hay un backend y si responde. +type EstadoSync = 'local' | 'comprobando' | 'conectado' | 'sin-conexion'; + +export default function CatalogoPage() { + const history = useHistory(); + const [filtro, setFiltro] = useState({}); + const [filas, setFilas] = useState([]); + const [tags, setTags] = useState([]); + const [total, setTotal] = useState(0); + const [modal, setModal] = useState(false); + const [sync, setSync] = useState( + container.sync.disponible() ? 'comprobando' : 'local', + ); + + // Comprueba si el backend responde ahora mismo. Sin backend configurado ⇒ 'local'. + const comprobarSync = useCallback(async () => { + if (!container.sync.disponible()) { setSync('local'); return; } + setSync('comprobando'); + setSync((await container.sync.probarConexion()) ? 'conectado' : 'sin-conexion'); + }, []); + + const recargar = useCallback(async (f: Filtro) => { + // Universo de tags disponibles (a partir del catálogo completo) para el filtro RF-014. + const todas = await container.catalogo.buscar({}); + setTotal(todas.length); + setTags([...new Set(todas.flatMap((o) => o.tags))].sort((a, b) => a.localeCompare(b, 'es'))); + + const obras = await container.catalogo.buscar(f); + const filas = await Promise.all( + obras.map(async (obra) => { + const actual = await container.progreso.actual(obra.id); + return { obra, capitulo: actual?.capitulo, ultima: actual?.registradoEn }; + }), + ); + setFilas(filas); + }, []); + + useIonViewWillEnter(() => { void recargar(filtro); void comprobarSync(); }); + + const aplicar = (patch: Partial) => { + const f = { ...filtro, ...patch }; + setFiltro(f); + void recargar(f); + }; + + const crear = async (input: NuevaObra) => { + await container.catalogo.crear(input); + await recargar(filtro); + }; + + const fileRef = useRef(null); + + // Modo absolutamente local: exporta todo a un archivo JSON (sin nube). + const exportar = async () => { + const backup = await container.backup.exportar(); + const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `capmark-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const importar = async (file: File) => { + try { + const backup = JSON.parse(await file.text()); + await container.backup.importar(backup); + await recargar(filtro); + } catch (e) { + alert(e instanceof Error ? e.message : 'No se pudo importar el archivo.'); + } + }; + + return ( + + + + Catálogo + + + + + fileRef.current?.click()} title="Importar backup"> + + + void comprobarSync()} + style={{ marginRight: 12, marginLeft: 4, cursor: 'pointer' }} + /> + + + + aplicar({ termino: e.detail.value ?? '' })} + /> + + + + aplicar({ estado: e.detail.value || undefined })}> + Todos + {ESTADOS_OBRA.map((s) => {s})} + + aplicar({ prioridad: e.detail.value || undefined })}> + Todas + {PRIORIDADES.map((p) => {p})} + + aplicar({ tag: e.detail.value || undefined })}> + Todos + {tags.map((t) => {t})} + + + + + + + {sync === 'sin-conexion' && ( + + No se puede sincronizar. Trabajando en modo local. + + )} + + {filas.length} obra{filas.length === 1 ? '' : 's'} {/* RF-014: contador de coincidencias */} + + + {filas.length === 0 ? ( +
+ {total === 0 ? ( + <> +

Tu catálogo está vacío.

+ { await sembrarDemo(); await recargar(filtro); }}> + Cargar datos de ejemplo + + + ) : ( +

Ninguna obra coincide con los filtros.

+ )} +
+ ) : ( + + {filas.map(({ obra, capitulo, ultima }) => ( + history.push(`/obra/${obra.id}`)}> + +

{obra.titulo}

+

+ {obra.tipo} · Cap. {capFmt(capitulo)} · Última: {fechaCorta(ultima)} +

+
+ {obra.tags.slice(0, 3).map((t) => {t})} +
+
+ {obra.prioridad} + {obra.estado} +
+ ))} +
+ )} + + + setModal(true)}> + + + +
+ + { + const f = e.target.files?.[0]; + if (f) void importar(f); + e.target.value = ''; + }} + /> + setModal(false)} onSave={crear} /> +
+ ); +} diff --git a/src/ui/pages/ObraDetallePage.tsx b/src/ui/pages/ObraDetallePage.tsx new file mode 100644 index 0000000..30c9e32 --- /dev/null +++ b/src/ui/pages/ObraDetallePage.tsx @@ -0,0 +1,245 @@ +import { useState } from 'react'; +import { + IonBackButton, IonBadge, IonButton, IonButtons, IonCard, IonCardContent, IonCardHeader, + IonCardSubtitle, IonCardTitle, IonChip, IonContent, IonHeader, IonIcon, IonItem, IonLabel, + IonList, IonListHeader, IonNote, IonPage, IonText, IonTitle, IonToolbar, useIonAlert, + useIonToast, useIonViewWillEnter, +} from '@ionic/react'; +import { + addCircleOutline, checkmarkCircle, createOutline, openOutline, refreshOutline, + searchCircleOutline, star, starOutline, trashOutline, +} from 'ionicons/icons'; +import { useHistory, useParams } from 'react-router-dom'; +import { NuevaObra } from '@application/ports'; +import { Fuente } from '@domain/fuente'; +import { Obra } from '@domain/obra'; +import { ProgresoEntry } from '@domain/progreso'; +import { container } from '@infrastructure/container'; +import ObraFormModal from '@ui/components/ObraFormModal'; +import { capFmt, colorEstado, colorPrioridad, fechaCorta } from '@ui/format'; + +export default function ObraDetallePage() { + const { id } = useParams<{ id: string }>(); + const history = useHistory(); + const [presentAlert] = useIonAlert(); + const [toast] = useIonToast(); + + const [obra, setObra] = useState(); + const [fuentes, setFuentes] = useState([]); + const [historial, setHistorial] = useState([]); + const [editar, setEditar] = useState(false); + + const cargar = async () => { + setObra(await container.catalogo.obtener(id)); + setFuentes(await container.fuentes.listar(id)); + setHistorial(await container.progreso.historial(id)); + }; + useIonViewWillEnter(() => { void cargar(); }); + + const actual = historial[0]; + + // RNF-002: actualizar capítulo en ≤ 3 toques (botón +1 = 1 toque). + const avanzar = async (delta: number) => { + const base = actual?.capitulo ?? 0; + await container.progreso.registrar(id, { capitulo: Math.max(0, base + delta) }); + await cargar(); + }; + + const fijarCapitulo = () => + presentAlert({ + header: 'Fijar capítulo', + inputs: [ + { name: 'capitulo', type: 'number', placeholder: 'Capítulo (admite 10.5)', value: actual?.capitulo }, + { name: 'punto', type: 'text', placeholder: 'Página / punto (opcional)' }, + ], + buttons: [ + 'Cancelar', + { + text: 'Guardar', + handler: async (d) => { + await container.progreso.registrar(id, { capitulo: Number.parseFloat(d.capitulo), punto: d.punto }); + await cargar(); + }, + }, + ], + }); + + const guardarEdicion = async (input: NuevaObra) => { + await container.catalogo.editar(id, input); + await cargar(); + }; + + const eliminarObra = () => + presentAlert({ + header: 'Eliminar obra', + message: 'Se borrarán también sus fuentes e historial. ¿Continuar?', + buttons: [ + 'Cancelar', + { + text: 'Eliminar', + role: 'destructive', + handler: async () => { + await container.catalogo.eliminar(id); + history.replace('/catalogo'); + }, + }, + ], + }); + + const agregarFuente = () => + presentAlert({ + header: 'Nueva fuente', + inputs: [ + { name: 'nombre', type: 'text', placeholder: 'Nombre en el sitio' }, + { name: 'url', type: 'url', placeholder: 'https://...' }, + ], + buttons: [ + 'Cancelar', + { + text: 'Agregar', + handler: async (d) => { + try { + setFuentes(await container.fuentes.agregar(id, { nombreEnSitio: d.nombre, url: d.url })); + } catch (e) { + toast({ message: e instanceof Error ? e.message : 'URL inválida', duration: 2500, color: 'danger' }); + } + }, + }, + ], + }); + + const verificar = async (f: Fuente) => { + toast({ message: `Verificando ${f.nombreEnSitio}…`, duration: 1200 }); + setFuentes(await container.fuentes.verificar(id, f.id)); + }; + + // Scraper OPT-IN: propone y el usuario confirma antes de registrar (semi-asistido, S2). + const detectar = async (f: Fuente) => { + toast({ message: 'Leyendo la fuente…', duration: 1200 }); + const p = await container.scraper.detectar(f.url).catch(() => undefined); + if (!p) { + toast({ message: 'No se pudo detectar el capítulo en esa fuente.', duration: 2500, color: 'warning' }); + return; + } + presentAlert({ + header: 'Capítulo detectado', + message: `Propuesta: capítulo ${capFmt(p.capitulo)} (confianza ${p.confianza}${p.etiqueta ? `, "${p.etiqueta}"` : ''}). ¿Registrarlo como tu punto actual?`, + buttons: [ + 'Descartar', + { + text: 'Registrar', + handler: async () => { + await container.progreso.registrar(id, { capitulo: p.capitulo }); + await cargar(); + }, + }, + ], + }); + }; + + const marcarPrincipal = async (f: Fuente) => setFuentes(await container.fuentes.marcarPrincipal(id, f.id)); + const eliminarFuente = async (f: Fuente) => setFuentes(await container.fuentes.eliminar(id, f.id)); + const abrir = (f: Fuente) => window.open(f.url, '_blank', 'noopener'); + + if (!obra) { + return ( + +

Cargando…

+
+ ); + } + + return ( + + + + + {obra.titulo} + + setEditar(true)}> + + + + + + +
+ {obra.estado}{' '} + {obra.prioridad}{' '} + {obra.tipo} +
+ {obra.nombresAlternativos.length > 0 && ( +

También: {obra.nombresAlternativos.join(' · ')}

+ )} +
{obra.tags.map((t) => {t})}
+ {obra.notas &&

{obra.notas}

} + + {/* Progreso (RF-011/012, RNF-002) */} + + + Punto actual + + Cap. {capFmt(actual?.capitulo)} + {actual?.punto && · {actual.punto}} + + Última lectura: {fechaCorta(actual?.registradoEn)} + + + avanzar(1)}>+1 capítulo + Fijar… + + + + {/* Fuentes (RF-008/009/010/017 + scraper opt-in) */} + + + Fuentes + Agregar + + {fuentes.length === 0 && Sin fuentes aún.} + {fuentes.map((f) => ( + + marcarPrincipal(f)} + title="Marcar principal" + /> + +

{f.nombreEnSitio}

+

+ {f.estadoVerificacion === 'activa' && }{' '} + {f.estadoVerificacion}{f.verificadaEn ? ` · ${fechaCorta(f.verificadaEn)}` : ''} +

+
+ + detectar(f)} title="Detectar capítulo (opt-in)"> + verificar(f)} title="Verificar link"> + abrir(f)} title="Abrir"> + eliminarFuente(f)}> + +
+ ))} +
+ + {/* Historial (RF-012) */} + + Historial de progreso + {historial.length === 0 && Sin registros.} + {historial.map((h) => ( + + +

Cap. {capFmt(h.capitulo)}

+ {h.punto &&

{h.punto}

} +
+ {fechaCorta(h.registradoEn)} +
+ ))} +
+
+ + setEditar(false)} onSave={guardarEdicion} /> +
+ ); +} diff --git a/src/ui/seed.ts b/src/ui/seed.ts new file mode 100644 index 0000000..cf6d9ac --- /dev/null +++ b/src/ui/seed.ts @@ -0,0 +1,37 @@ +import { container } from '@infrastructure/container'; + +/** Datos de ejemplo para probar la app sin backend (útil en la primera ejecución). */ +export async function sembrarDemo(): Promise { + const solo = await container.catalogo.crear({ + titulo: 'Solo Leveling', + tipo: 'manhua', + nombresAlternativos: ['나 혼자만 레벨업', 'Only I Level Up'], + tags: ['acción', 'fantasía'], + estado: 'leyendo', + prioridad: 'alta', + }); + await container.fuentes.agregar(solo.id, { nombreEnSitio: 'Asura Scans', url: 'https://asuracomic.net/', esPrincipal: true }); + await container.fuentes.agregar(solo.id, { nombreEnSitio: 'MangaDex', url: 'https://mangadex.org/' }); + await container.progreso.registrar(solo.id, { capitulo: 178 }); + await container.progreso.registrar(solo.id, { capitulo: 179.5, punto: 'primera mitad' }); + + const omni = await container.catalogo.crear({ + titulo: 'Omniscient Reader', + tipo: 'manhua', + nombresAlternativos: ['전지적 독자 시점', 'ORV'], + tags: ['aventura', 'apocalipsis'], + estado: 'pausado', + prioridad: 'media', + }); + await container.fuentes.agregar(omni.id, { nombreEnSitio: 'Webtoon', url: 'https://www.webtoons.com/', esPrincipal: true }); + await container.progreso.registrar(omni.id, { capitulo: 92 }); + + const mushoku = await container.catalogo.crear({ + titulo: 'Mushoku Tensei', + tipo: 'novela', + tags: ['isekai'], + estado: 'pendiente', + prioridad: 'baja', + }); + await container.fuentes.agregar(mushoku.id, { nombreEnSitio: 'NovelBin', url: 'https://novelbin.com/' }); +} diff --git a/src/ui/theme.css b/src/ui/theme.css new file mode 100644 index 0000000..7bb1ba8 --- /dev/null +++ b/src/ui/theme.css @@ -0,0 +1,19 @@ +:root { + --ion-color-primary: #5b5bd6; + --ion-color-primary-shade: #4f4fbc; + --ion-color-primary-tint: #6c6cda; +} + +.estado-badge { text-transform: capitalize; } +.cap-actual { + font-variant-numeric: tabular-nums; + font-weight: 700; +} +.fuente-caida { opacity: 0.55; } +.muted { color: var(--ion-color-medium); font-size: 0.85rem; } +.big-cap { + font-size: 2.6rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..5076afc --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/// + +interface ImportMetaEnv { + /** Backend auto-instanciable (opcional). Sin estas variables, la app es 100% local. */ + readonly VITE_SUPABASE_URL?: string; + readonly VITE_SUPABASE_ANON_KEY?: string; + /** Edge Function de verificación de links (RF-017). */ + readonly VITE_VERIFY_URL?: string; + /** Proxy/Edge Function para el scraper opt-in (evita CORS). */ + readonly VITE_SCRAPER_PROXY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..27c2bfd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@domain/*": ["src/domain/*"], + "@application/*": ["src/application/*"], + "@infrastructure/*": ["src/infrastructure/*"], + "@ui/*": ["src/ui/*"], + "@test/*": ["src/test/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..48231dd --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,33 @@ +/// +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@domain': path.resolve(__dirname, 'src/domain'), + '@application': path.resolve(__dirname, 'src/application'), + '@infrastructure': path.resolve(__dirname, 'src/infrastructure'), + '@ui': path.resolve(__dirname, 'src/ui'), + '@test': path.resolve(__dirname, 'src/test'), + }, + }, + build: { + // El core de Ionic (~1.3 MB) es un vendor conocido y cacheable; no es código nuestro. + chunkSizeWarningLimit: 1400, + rollupOptions: { + output: { + manualChunks: { + react: ['react', 'react-dom', 'react-router', 'react-router-dom'], + ionic: ['@ionic/react', '@ionic/react-router', 'ionicons'], + }, + }, + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});