diff --git a/.env.example b/.env.example index d2c1540..b724bd4 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,14 @@ # 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 +# Para sincronización BYOS con Google Drive, rellena VITE_GOOGLE_CLIENT_ID. -# --- Backend (Supabase self-hosted; ver infra/docker-compose.yml) --- -VITE_SUPABASE_URL=http://localhost:8000 -VITE_SUPABASE_ANON_KEY= +# --- Google Drive BYOS Sync --- +# Crea un proyecto en https://console.cloud.google.com, habilita la Drive API +# y genera un OAuth 2.0 Client ID (tipo "Web application"). +# Autoriza como origen: http://localhost:5173 (desarrollo) y tu dominio en producción. +VITE_GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com -# --- 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= +# --- Proxy opcional para verificación de links (RF-017) --- +# Sin backend, la verificación usa no-cors como fallback (best-effort). +# Para verificación confiable, apunta a un worker (ej. Cloudflare Workers): +# VITE_VERIFY_URL=https://tu-worker.workers.dev/verify-link +# VITE_SCRAPER_PROXY=https://tu-worker.workers.dev/fetch-html diff --git a/Docs/05-stack-sugerido.md b/Docs/05-stack-sugerido.md index 55127fa..13bc35d 100644 --- a/Docs/05-stack-sugerido.md +++ b/Docs/05-stack-sugerido.md @@ -31,8 +31,31 @@ | Backend / sync | **Firebase (Firestore + Auth)** | Sync offline muy pulido, pero NoSQL: el modelo relacional Obra/Fuente/Progreso queda menos natural y hay más *lock-in*. | | Sync avanzado (opcional) | **PowerSync / ElectricSQL** sobre Postgres | Sync local-first con Postgres si la calidad offline es crítica; más piezas que mantener para un proyecto personal. | +## Opción C — BYOS Google Drive (**implementada**) + +| Capa | Elección | Justificación | +|------|----------|-| +| App / UI | **Ionic + Capacitor** con TypeScript (React) | Sin cambios frente a Opción A. | +| Persistencia local + offline | **Dexie / IndexedDB** | Ídem. Los datos locales siguen siendo la fuente de verdad. | +| Backend, auth y sync | **Google Drive `appDataFolder`** (BYOS) | Costo $0; el usuario autentifica con su propia cuenta Google via OAuth 2.0 (`drive.appdata`). La carpeta es invisible para el usuario y aislada por `client_id`. | +| Auth | **`@react-oauth/google`** (popup nativo) | Sin servidor propio de auth; Google gestiona tokens y renovación. | +| Verificación de links | Fallback `no-cors` desde cliente (best-effort) | Sin Edge Function; RF-017 es `Should`. Opcional: Cloudflare Worker gratuito como proxy. | +| Estrategia de conflictos | Última escritura gana por `exportadoEn` | Igual que la Opción A pero sin Realtime; sync manual o al abrir la app. | + +### Ventajas frente a Opción A + +- **Costo cero de infraestructura**: no hay Docker, Postgres ni servidor que mantener. +- **Cero fricción de instanciación**: `VITE_GOOGLE_CLIENT_ID` en `.env` es el único requisito. +- **Privacidad**: los datos viven en la cuenta personal del usuario, no en un servidor tuyo. + +### Limitaciones frente a Opción A + +- Sync manual (no Realtime en tiempo real); suficiente para uso personal. +- RF-017 (verificación de links) en modo best-effort sin proxy externo. +- Requiere conexión para sincronizar (la app sigue funcionando offline en local). + ## Recomendación -Para un MVP personal con multi-dispositivo, offline y modelo relacional, **Opción A** -(Ionic + Capacitor + Supabase) ofrece el mejor equilibrio entre esfuerzo, portabilidad y -reutilización de lo que ya dominas, y deja el camino abierto al bot de Telegram en fase 2. +Para un MVP personal local-first de costo cero, **Opción C** (BYOS Google Drive) es la +elección óptima. Elimina toda la infraestructura self-hosted manteniendo la arquitectura +en capas que permite volver a la Opción A en cualquier momento cambiando solo `container.ts`. diff --git a/Docs/06-plan-de-trabajo.md b/Docs/06-plan-de-trabajo.md index 1aab458..9f7ae85 100644 --- a/Docs/06-plan-de-trabajo.md +++ b/Docs/06-plan-de-trabajo.md @@ -6,14 +6,13 @@ --- -## 0. Principio rector: infraestructura auto-instanciable +## 0. Principio rector: local-first + BYOS -Requisito explícito del proyecto: **no montar Supabase (ni backend alguno) a mano**. Toda la -infraestructura debe poder levantarse **con un comando**, versionada como código, de modo que -cualquier persona (o cualquier dispositivo/entorno) instancie su propio backend sin pasos -manuales en paneles web. +Requisito explícito del proyecto: **costo cero de infraestructura**. La sincronización +usa la cuenta personal de Google del usuario (`appDataFolder`, invisible en Drive UI). +La app es completamente funcional sin conexión; sync es una acción manual. -Esto se traduce en reglas que atraviesan **todas** las fases: +Reglas que atraviesan **todas** las fases: - **Backend self-hosted por Docker Compose.** Nada de crear proyectos a mano en un panel. `docker compose up` levanta Postgres + Auth + Realtime + Edge Functions. @@ -88,11 +87,13 @@ scripts/ - **Entregable:** CRUD local funcional sin red; los cambios quedan encolados. - **Cubre:** RNF-004 (offline), RNF-007 (no pérdida de progreso). -### Fase 3 — Autenticación y sincronización (F-05) -- Supabase Auth self-hosted; sesión persistente entre aperturas (RF-015). -- Motor de sync: sube el outbox al reconectar, recibe cambios por Realtime, resuelve por LWW. -- **Entregable:** dos dispositivos/usuarios sincronizando altas, cambios y borrados. -- **Cubre:** RF-015, RF-016; RNF-005 (< 10 s), RNF-006 (HTTPS/TLS en transporte). +### Fase 3 — Autenticación y sincronización BYOS (F-05) — **implementada** +- Google OAuth 2.0 con scope `drive.appdata`; token gestionado por `GoogleAuthStore` en memoria. +- Motor de sync: `GoogleDriveSync` sube el catálogo completo como `capmark-db.json` a `appDataFolder` + (invisible para el usuario). Pull descarga y fusiona por LWW (`exportadoEn`). +- UI: `SyncPanel` (modal desde toolbar) con botones subir / bajar / sincronizar. +- **Entregable:** dos dispositivos del mismo usuario sincronizan el catálogo vía Google Drive. +- **Cubre:** RF-015 (auth via Google), RF-016 (sync offline-first); RNF-006 (HTTPS/TLS nativo en Drive API). ### Fase 4 — UI de obras y fuentes (F-01, F-02) - Catálogo (lista) + alta/edición de obra: título, tipo, alias, tags, estado, prioridad, notas. diff --git a/package-lock.json b/package-lock.json index fae316d..544449d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ionic/react": "^8.4.0", "@ionic/react-router": "^8.4.0", + "@react-oauth/google": "^0.13.5", "dexie": "^4.0.10", "ionicons": "^7.4.0", "react": "^18.3.1", @@ -824,6 +825,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.5.tgz", + "integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", diff --git a/package.json b/package.json index ff503ad..ff08aaf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@ionic/react": "^8.4.0", "@ionic/react-router": "^8.4.0", + "@react-oauth/google": "^0.13.5", "dexie": "^4.0.10", "ionicons": "^7.4.0", "react": "^18.3.1", diff --git a/src/application/ports.ts b/src/application/ports.ts index e2ff7c7..4af0fa9 100644 --- a/src/application/ports.ts +++ b/src/application/ports.ts @@ -63,4 +63,12 @@ export interface SyncPort { probarConexion(): Promise; } +/** Autenticación opcional con un proveedor externo (Google). La app funciona sin él. */ +export interface AuthPort { + getToken(): string | null; + setToken(token: string, userEmail?: string): void; + getUserEmail(): string | null; + logout(): void; +} + export type { NuevaObra, NuevaFuente, NuevoProgreso }; diff --git a/src/infrastructure/auth/google-auth.ts b/src/infrastructure/auth/google-auth.ts new file mode 100644 index 0000000..69f08ae --- /dev/null +++ b/src/infrastructure/auth/google-auth.ts @@ -0,0 +1,39 @@ +import { AuthPort } from '@application/ports'; + +/** + * Store en memoria para el token OAuth de Google Drive. + * No persiste en localStorage: los access tokens expiran en ~1 hora y + * @react-oauth/google los renueva silenciosamente. Solo el email se guarda + * en sessionStorage para mostrarlo en la UI entre recargas dentro de la misma sesión. + */ +export class GoogleAuthStore implements AuthPort { + private token: string | null = null; + private email: string | null = null; + + constructor() { + // Restaurar email de sesión (cosmético, el token siempre se reobtiene via OAuth) + this.email = sessionStorage.getItem('capmark_google_email'); + } + + getToken(): string | null { + return this.token; + } + + setToken(token: string, userEmail?: string): void { + this.token = token; + if (userEmail) { + this.email = userEmail; + sessionStorage.setItem('capmark_google_email', userEmail); + } + } + + getUserEmail(): string | null { + return this.email; + } + + logout(): void { + this.token = null; + this.email = null; + sessionStorage.removeItem('capmark_google_email'); + } +} diff --git a/src/infrastructure/container.ts b/src/infrastructure/container.ts index 1c7ec15..395c040 100644 --- a/src/infrastructure/container.ts +++ b/src/infrastructure/container.ts @@ -6,23 +6,27 @@ 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 { GoogleAuthStore } from './auth/google-auth'; import { db } from './persistence/db'; import { DexieRepositorio } from './persistence/dexie-repositorio'; import { GenericScraper } from './scraper/generic-scraper'; -import { SupabaseSync } from './sync/supabase-sync'; +import { GoogleDriveSync } from './sync/google-drive-sync'; import { idGen, reloj } from './system'; import { HttpLinkVerifier } from './verify/link-verifier'; const repo = new DexieRepositorio(db); const verifier = new HttpLinkVerifier(); +const authStore = new GoogleAuthStore(); +const backupService = new BackupService(repo); 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(), + backup: backupService, + auth: authStore, + sync: new GoogleDriveSync(authStore, backupService), }; export type Container = typeof container; diff --git a/src/infrastructure/sync/google-drive-sync.ts b/src/infrastructure/sync/google-drive-sync.ts new file mode 100644 index 0000000..da7bdaf --- /dev/null +++ b/src/infrastructure/sync/google-drive-sync.ts @@ -0,0 +1,150 @@ +import { BackupService } from '@application/backup-service'; +import { AuthPort, SyncPort } from '@application/ports'; + +const DRIVE_API = 'https://www.googleapis.com/drive/v3'; +const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; +const FILE_NAME = 'capmark-db.json'; + +/** + * Adaptador de sincronización BYOS sobre la appDataFolder de Google Drive del usuario. + * + * Estrategia: el backup completo se serializa como un único JSON (`capmark-db.json`) + * guardado en la carpeta oculta `appDataFolder` (invisible para el usuario en Drive UI, + * aislada por el Client ID de la app). Conflictos: última escritura gana por `exportadoEn`. + * + * Ciclo de sync: + * push() → exportar() localmente → subir a Drive (PUT si existe, POST si no). + * pull() → descargar de Drive → importar() con merge LWW. + * + * La app es local-first: sin token OAuth, `disponible()` es false y todo sigue en local. + * Implementa SyncPort (RNF-009); el dominio y la UI no conocen esta clase. + */ +export class GoogleDriveSync implements SyncPort { + constructor( + private auth: AuthPort, + private backup: BackupService, + ) {} + + disponible(): boolean { + return this.auth.getToken() !== null; + } + + async probarConexion(): Promise { + if (!this.disponible()) return false; + try { + const res = await fetch(`${DRIVE_API}/about?fields=user`, { + headers: this.headers(), + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } + } + + /** + * Exporta el catálogo completo y lo sube a appDataFolder/capmark-db.json. + * Si el archivo ya existe en Drive, lo sobreescribe (PUT multipart). + * Si no existe, lo crea (POST multipart). + */ + async push(): Promise { + if (!this.disponible()) return; + const data = await this.backup.exportar(); + const body = JSON.stringify(data); + const existingId = await this.findFileId(); + + if (existingId) { + await this.updateFile(existingId, body); + } else { + await this.createFile(body); + } + } + + /** + * Descarga capmark-db.json de appDataFolder y lo fusiona con el estado local. + * Estrategia LWW: si `exportadoEn` remoto > local, se importa; si no, se ignora. + */ + async pull(): Promise { + if (!this.disponible()) return; + const fileId = await this.findFileId(); + if (!fileId) return; // primera vez: Drive está vacío, no hay nada que bajar + + const res = await fetch(`${DRIVE_API}/files/${fileId}?alt=media`, { + headers: this.headers(), + }); + if (!res.ok) return; + + const remote = await res.json(); + + // LWW: solo importamos si el remoto es más reciente que nuestro último export + const localBackup = await this.backup.exportar(); + const remoteTs = new Date(remote.exportadoEn ?? 0).getTime(); + const localTs = new Date(localBackup.exportadoEn ?? 0).getTime(); + if (remoteTs > localTs) { + await this.backup.importar(remote, /* reemplazar= */ true); + } + } + + // ── helpers privados ──────────────────────────────────────────────────────── + + private headers(): Record { + return { Authorization: `Bearer ${this.auth.getToken()}` }; + } + + /** Busca el archivo por nombre en appDataFolder. Devuelve su id o null. */ + private async findFileId(): Promise { + const q = encodeURIComponent( + `name='${FILE_NAME}' and 'appDataFolder' in parents and trashed=false`, + ); + const res = await fetch(`${DRIVE_API}/files?spaces=appDataFolder&q=${q}&fields=files(id)`, { + headers: this.headers(), + }); + if (!res.ok) return null; + const data = (await res.json()) as { files: { id: string }[] }; + return data.files[0]?.id ?? null; + } + + /** Crea el archivo en appDataFolder (primera vez). */ + private async createFile(body: string): Promise { + const metadata = { name: FILE_NAME, parents: ['appDataFolder'] }; + const form = this.buildMultipart(metadata, body); + await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': form.contentType }, + body: form.body, + }); + } + + /** Sobreescribe el archivo existente (actualizaciones sucesivas). */ + private async updateFile(fileId: string, body: string): Promise { + const form = this.buildMultipart({}, body); + await fetch(`${DRIVE_UPLOAD}/files/${fileId}?uploadType=multipart`, { + method: 'PATCH', + headers: { ...this.headers(), 'Content-Type': form.contentType }, + body: form.body, + }); + } + + /** + * Construye un cuerpo multipart/related para la Drive API. + * Parte 1: metadatos JSON. Parte 2: contenido JSON de la base de datos. + */ + private buildMultipart( + metadata: object, + content: string, + ): { contentType: string; body: string } { + const boundary = 'capmark_boundary'; + const body = [ + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + JSON.stringify(metadata), + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + content, + `--${boundary}--`, + ].join('\r\n'); + return { contentType: `multipart/related; boundary=${boundary}`, body }; + } +} diff --git a/src/infrastructure/sync/supabase-sync.ts b/src/infrastructure/sync/supabase-sync.ts deleted file mode 100644 index a95b9e5..0000000 --- a/src/infrastructure/sync/supabase-sync.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/ui/App.tsx b/src/ui/App.tsx index ce1366e..fbc8b93 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react'; import { IonApp, IonRouterOutlet, IonSpinner } from '@ionic/react'; import { IonReactRouter } from '@ionic/react-router'; +import { GoogleOAuthProvider } from '@react-oauth/google'; import { Redirect, Route } from 'react-router-dom'; // Rutas con carga diferida: cada página es su propio chunk (bundle inicial más liviano). @@ -11,8 +12,12 @@ const Cargando = () => (
); +// El clientId viene de VITE_GOOGLE_CLIENT_ID en .env (ver .env.example). +// Sin él, la app sigue funcionando en modo solo-local; el botón de sync no aparece. +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined; + export default function App() { - return ( + const app = ( }> @@ -27,4 +32,13 @@ export default function App() { ); + + // Si no hay Client ID configurado, la app funciona igual en modo solo-local. + if (!GOOGLE_CLIENT_ID) return app; + + return ( + + {app} + + ); } diff --git a/src/ui/components/SyncPanel.tsx b/src/ui/components/SyncPanel.tsx new file mode 100644 index 0000000..76abc8d --- /dev/null +++ b/src/ui/components/SyncPanel.tsx @@ -0,0 +1,198 @@ +import { useState, useCallback } from 'react'; +import { + IonButton, IonButtons, IonContent, IonHeader, IonIcon, + IonItem, IonLabel, IonModal, IonNote, IonSpinner, IonTitle, IonToolbar, +} from '@ionic/react'; +import { + logoGoogle, cloudDoneOutline, cloudOfflineOutline, + syncOutline, logOutOutline, cloudUploadOutline, cloudDownloadOutline, +} from 'ionicons/icons'; +import { useGoogleLogin } from '@react-oauth/google'; +import { container } from '@infrastructure/container'; + +type EstadoSync = 'desconectado' | 'conectando' | 'conectado' | 'sincronizando' | 'error'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onSyncCompleto?: () => void; // para que CatalogoPage recargue tras un pull +} + +/** + * Panel de sincronización con Google Drive (BYOS). + * Maneja el ciclo completo: OAuth login → push/pull → logout. + * La app sigue funcionando si el usuario no conecta: local-first (RNF-004). + */ +export default function SyncPanel({ isOpen, onClose, onSyncCompleto }: Props) { + const [estado, setEstado] = useState( + container.sync.disponible() ? 'conectado' : 'desconectado', + ); + const [mensaje, setMensaje] = useState(''); + const email = container.auth.getUserEmail(); + + const login = useGoogleLogin({ + scope: 'https://www.googleapis.com/auth/drive.appdata', + onSuccess: (tokenResponse) => { + container.auth.setToken(tokenResponse.access_token); + setEstado('conectado'); + setMensaje('Conectado. Sincroniza para subir o bajar tus datos.'); + }, + onError: () => { + setEstado('error'); + setMensaje('No se pudo conectar con Google. Inténtalo de nuevo.'); + }, + }); + + const handleLogin = useCallback(() => { + setEstado('conectando'); + setMensaje(''); + login(); + }, [login]); + + const sincronizar = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.push(); + await container.sync.pull(); + setEstado('conectado'); + setMensaje(`Sincronizado · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + onSyncCompleto?.(); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al sincronizar.'); + } + }, [onSyncCompleto]); + + const subir = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.push(); + setEstado('conectado'); + setMensaje(`Datos subidos a Drive · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al subir.'); + } + }, []); + + const bajar = useCallback(async () => { + if (!container.sync.disponible()) return; + setEstado('sincronizando'); + setMensaje(''); + try { + await container.sync.pull(); + setEstado('conectado'); + setMensaje(`Datos bajados de Drive · ${new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })}`); + onSyncCompleto?.(); + } catch (e) { + setEstado('error'); + setMensaje(e instanceof Error ? e.message : 'Error al bajar.'); + } + }, [onSyncCompleto]); + + const logout = useCallback(() => { + container.auth.logout(); + setEstado('desconectado'); + setMensaje(''); + }, []); + + const cargando = estado === 'conectando' || estado === 'sincronizando'; + + return ( + + + + Google Drive Sync + + Cerrar + + + + + + + {/* ── Estado de conexión ── */} + + + +

+ {{ + desconectado: 'Sin conectar', + conectando: 'Conectando…', + conectado: email ? `Conectado como ${email}` : 'Conectado', + sincronizando: 'Sincronizando…', + error: 'Error', + }[estado]} +

+

+ Tus datos se guardan en tu propio Google Drive, de forma privada y cifrada por Google. +

+
+ {cargando && } +
+ + {/* ── Mensaje de estado ── */} + {mensaje && ( + + {mensaje} + + )} + + {/* ── Panel desconectado ── */} + {estado === 'desconectado' && ( +
+ + + Conectar con Google Drive + + + Solo se solicita acceso a la carpeta privada de CapMark. + Ninguna otra app podrá leer tus datos. + +
+ )} + + {/* ── Panel conectado ── */} + {(estado === 'conectado' || estado === 'sincronizando' || estado === 'error') && container.sync.disponible() && ( +
+ void sincronizar()} disabled={cargando}> + + Sincronizar (subir y bajar) + + void subir()} disabled={cargando}> + + Solo subir + + void bajar()} disabled={cargando}> + + Solo bajar + + + + Desconectar cuenta + +
+ )} + + {/* ── Nota informativa ── */} + + Los datos se guardan en una carpeta oculta de tu Drive, invisible en la interfaz normal + de Google Drive. Solo CapMark puede leerla. Puedes seguir usando la app sin conexión: + los cambios se sincronizarán la próxima vez que pulses "Sincronizar". + + +
+
+ ); +} diff --git a/src/ui/pages/CatalogoPage.tsx b/src/ui/pages/CatalogoPage.tsx index 3201538..7eaa3a6 100644 --- a/src/ui/pages/CatalogoPage.tsx +++ b/src/ui/pages/CatalogoPage.tsx @@ -1,11 +1,10 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, 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 { add, cloudOutline, downloadOutline, folderOpenOutline } from 'ionicons/icons'; import { useHistory } from 'react-router-dom'; import { Filtro } from '@application/catalogo-service'; import { NuevaObra } from '@application/ports'; @@ -13,15 +12,12 @@ import { Obra } from '@domain/obra'; import { ESTADOS_OBRA, PRIORIDADES } from '@domain/types'; import { container } from '@infrastructure/container'; import ObraFormModal from '@ui/components/ObraFormModal'; +import SyncPanel from '@ui/components/SyncPanel'; 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({}); @@ -29,16 +25,7 @@ export default function CatalogoPage() { 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 [syncPanel, setSyncPanel] = useState(false); const recargar = useCallback(async (f: Filtro) => { // Universo de tags disponibles (a partir del catálogo completo) para el filtro RF-014. @@ -56,7 +43,7 @@ export default function CatalogoPage() { setFilas(filas); }, []); - useIonViewWillEnter(() => { void recargar(filtro); void comprobarSync(); }); + useIonViewWillEnter(() => { void recargar(filtro); }); const aplicar = (patch: Partial) => { const f = { ...filtro, ...patch }; @@ -105,18 +92,16 @@ export default function CatalogoPage() { fileRef.current?.click()} title="Importar backup"> - void comprobarSync()} - style={{ marginRight: 12, marginLeft: 4, cursor: 'pointer' }} - /> + setSyncPanel(true)} + title={container.sync.disponible() ? 'Google Drive conectado' : 'Conectar Google Drive'} + > + + @@ -145,11 +130,7 @@ export default function CatalogoPage() { - {sync === 'sin-conexion' && ( - - No se puede sincronizar. Trabajando en modo local. - - )} + {filas.length} obra{filas.length === 1 ? '' : 's'} {/* RF-014: contador de coincidencias */} @@ -206,6 +187,11 @@ export default function CatalogoPage() { }} /> setModal(false)} onSave={crear} /> + setSyncPanel(false)} + onSyncCompleto={() => void recargar(filtro)} + /> ); }