diff --git a/src/ui/components/ObraFormModal.tsx b/src/ui/components/ObraFormModal.tsx index 9806140..e3b9e60 100644 --- a/src/ui/components/ObraFormModal.tsx +++ b/src/ui/components/ObraFormModal.tsx @@ -8,6 +8,7 @@ import { NuevaObra } from '@application/ports'; import { Obra } from '@domain/obra'; import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA, ESTADOS_PUBLICACION } from '@domain/types'; import { container } from '@infrastructure/container'; +import StarRating from '@ui/components/StarRating'; interface Props { isOpen: boolean; @@ -260,17 +261,10 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props) - Calificación (0 a 5) - { - const val = e.detail.value; - setField({ calificacion: val ? parseFloat(val) : undefined }); - }} + Calificación (0 a 5) + setField({ calificacion: val || undefined })} /> diff --git a/src/ui/components/StarRating.tsx b/src/ui/components/StarRating.tsx new file mode 100644 index 0000000..1c769fa --- /dev/null +++ b/src/ui/components/StarRating.tsx @@ -0,0 +1,48 @@ +import { IonIcon } from '@ionic/react'; +import { star, starOutline, starHalf } from 'ionicons/icons'; + +interface Props { + value: number; // 0 to 5 + onChange?: (value: number) => void; + readonly?: boolean; +} + +export default function StarRating({ value, onChange, readonly = false }: Props) { + const handleClick = (index: number) => { + if (readonly || !onChange) return; + + // We can allow half stars by calculating where the user clicked, + // but for a simple visual star rating, clicking a star usually sets it to that integer value. + // Let's implement a simple integer star rating first. If the user clicks the same star again, maybe reset or decrease? + // A common approach is clicking a star sets it. Clicking the same star if it was the only one might reset it. + if (value === index + 1) { + onChange(0); + } else { + onChange(index + 1); + } + }; + + return ( +
+ {[0, 1, 2, 3, 4].map((i) => { + const fill = value - i; + let icon = starOutline; + if (fill >= 1) icon = star; + else if (fill >= 0.5) icon = starHalf; + + return ( + handleClick(i)} + /> + ); + })} +
+ ); +} diff --git a/src/ui/components/SyncPanel.tsx b/src/ui/components/SyncPanel.tsx index 7cf3470..939cd80 100644 --- a/src/ui/components/SyncPanel.tsx +++ b/src/ui/components/SyncPanel.tsx @@ -18,12 +18,41 @@ interface Props { onSyncCompleto?: () => void; // para que CatalogoPage recargue tras un pull } +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined; + /** * 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) { +export default function SyncPanel(props: Props) { + if (!GOOGLE_CLIENT_ID) { + return ( + + + + Google Drive Sync + + Cerrar + + + + + + +

No configurado

+
+ + La sincronización en la nube no está habilitada. Falta configurar VITE_GOOGLE_CLIENT_ID en tu archivo .env. + +
+
+ ); + } + return ; +} + +function SyncPanelInner({ isOpen, onClose, onSyncCompleto }: Props) { const [estado, setEstado] = useState( container.sync.disponible() ? 'conectado' : 'desconectado', ); diff --git a/src/ui/pages/CatalogoPage.tsx b/src/ui/pages/CatalogoPage.tsx index 5b5c18e..0f00348 100644 --- a/src/ui/pages/CatalogoPage.tsx +++ b/src/ui/pages/CatalogoPage.tsx @@ -15,6 +15,7 @@ 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'; +import StarRating from '@ui/components/StarRating'; interface Fila { obra: Obra; capitulo?: number; ultima?: string; } @@ -28,24 +29,42 @@ export default function CatalogoPage() { const [syncPanel, setSyncPanel] = useState(false); const [welcomeModal, setWelcomeModal] = useState(false); - const recargar = useCallback(async (f: Filtro) => { + const [orden, setOrden] = useState<'ultima' | 'alfabetico' | 'calificacion'>('ultima'); + + const recargar = useCallback(async (f: Filtro, ord: string = orden) => { // 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( + let filas = await Promise.all( obras.map(async (obra) => { const actual = await container.progreso.actual(obra.id); return { obra, capitulo: actual?.capitulo, ultima: actual?.registradoEn }; }), ); + + filas.sort((a, b) => { + if (ord === 'alfabetico') { + return a.obra.titulo.localeCompare(b.obra.titulo, 'es'); + } + if (ord === 'calificacion') { + const calA = a.obra.calificacion || 0; + const calB = b.obra.calificacion || 0; + return calB - calA; + } + // por defecto: 'ultima' + const ultimaA = a.ultima || a.obra.actualizadaEn || a.obra.creadaEn; + const ultimaB = b.ultima || b.obra.actualizadaEn || b.obra.creadaEn; + return ultimaB.localeCompare(ultimaA); + }); + setFilas(filas); if (todas.length === 0) { setWelcomeModal(true); } - }, []); + }, [orden]); useIonViewWillEnter(() => { void recargar(filtro); }); @@ -55,6 +74,11 @@ export default function CatalogoPage() { void recargar(f); }; + const aplicarOrden = (nuevoOrden: 'ultima' | 'alfabetico' | 'calificacion') => { + setOrden(nuevoOrden); + void recargar(filtro, nuevoOrden); + }; + const crear = async (input: NuevaObra) => { await container.catalogo.crear(input); await recargar(filtro); @@ -133,6 +157,11 @@ export default function CatalogoPage() { Cualquiera {ESTADOS_PUBLICACION.map((s) => {s})} + aplicarOrden(e.detail.value)}> + Última lectura + Alfabético + Calificación + @@ -165,10 +194,15 @@ export default function CatalogoPage() { history.push(`/obra/${obra.id}`)}>

{obra.titulo}

-

- {obra.tipo} · Cap. {capFmt(capitulo)} · Última: {fechaCorta(ultima)} - {obra.calificacion ? ` · ⭐ ${obra.calificacion}` : ''} - {obra.autor ? ` · 👤 ${obra.autor}` : ''} +

+ {obra.tipo} · Cap. {capFmt(capitulo)} · Última: {fechaCorta(ultima)} + {obra.calificacion ? ( + <> + · + + + ) : null} + {obra.autor ? · 👤 {obra.autor} : null}

{obra.tags.slice(0, 3).map((t) => {t})} diff --git a/src/ui/pages/ObraDetallePage.tsx b/src/ui/pages/ObraDetallePage.tsx index a80484c..56c6e5b 100644 --- a/src/ui/pages/ObraDetallePage.tsx +++ b/src/ui/pages/ObraDetallePage.tsx @@ -16,6 +16,7 @@ import { Obra } from '@domain/obra'; import { ProgresoEntry } from '@domain/progreso'; import { container } from '@infrastructure/container'; import ObraFormModal from '@ui/components/ObraFormModal'; +import StarRating from '@ui/components/StarRating'; import { capFmt, colorEstado, colorPrioridad, fechaCorta } from '@ui/format'; export default function ObraDetallePage() { @@ -201,7 +202,11 @@ export default function ObraDetallePage() { {obra.autor &&

Autor: {obra.autor}

} {obra.artista &&

Artista: {obra.artista}

} {obra.estadoPublicacion &&

Publicación: {obra.estadoPublicacion}

} - {obra.calificacion !== undefined &&

Calificación: ⭐ {obra.calificacion}

} + {obra.calificacion !== undefined && ( +
+ Calificación: +
+ )}
{relacionadas.length > 0 && (