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 10 Capítulo 42 Cap. 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'],
+ },
+});