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 && (