Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions src/ui/components/ObraFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -260,17 +261,10 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
</IonItem>

<IonItem>
<IonLabel position="stacked">Calificación (0 a 5)</IonLabel>
<IonInput
type="number"
step="0.5"
min="0"
max="5"
value={form.calificacion ?? ''}
onIonInput={(e) => {
const val = e.detail.value;
setField({ calificacion: val ? parseFloat(val) : undefined });
}}
<IonLabel position="stacked" style={{ marginBottom: '8px' }}>Calificación (0 a 5)</IonLabel>
<StarRating
value={form.calificacion ?? 0}
onChange={(val) => setField({ calificacion: val || undefined })}
/>
</IonItem>

Expand Down
48 changes: 48 additions & 0 deletions src/ui/components/StarRating.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: 'inline-flex', gap: '4px', alignItems: 'center' }}>
{[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 (
<IonIcon
key={i}
icon={icon}
style={{
fontSize: '24px',
color: 'var(--ion-color-warning, #ffc409)',
cursor: readonly ? 'default' : 'pointer',
}}
onClick={() => handleClick(i)}
/>
);
})}
</div>
);
}
31 changes: 30 additions & 1 deletion src/ui/components/SyncPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<IonModal isOpen={props.isOpen} onDidDismiss={props.onClose}>
<IonHeader>
<IonToolbar>
<IonTitle>Google Drive Sync</IonTitle>
<IonButtons slot="end">
<IonButton onClick={props.onClose}>Cerrar</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonItem lines="none" style={{ marginBottom: 8 }}>
<IonIcon icon={cloudOfflineOutline} color="medium" slot="start" />
<IonLabel><h2>No configurado</h2></IonLabel>
</IonItem>
<IonNote color="warning" style={{ display: 'block', padding: '16px', fontSize: 13 }}>
La sincronización en la nube no está habilitada. Falta configurar <strong>VITE_GOOGLE_CLIENT_ID</strong> en tu archivo <code>.env</code>.
</IonNote>
</IonContent>
</IonModal>
);
}
return <SyncPanelInner {...props} />;
}

function SyncPanelInner({ isOpen, onClose, onSyncCompleto }: Props) {
const [estado, setEstado] = useState<EstadoSync>(
container.sync.disponible() ? 'conectado' : 'desconectado',
);
Expand Down
48 changes: 41 additions & 7 deletions src/ui/pages/CatalogoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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); });

Expand All @@ -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);
Expand Down Expand Up @@ -133,6 +157,11 @@ export default function CatalogoPage() {
<IonSelectOption value="">Cualquiera</IonSelectOption>
{ESTADOS_PUBLICACION.map((s) => <IonSelectOption key={s} value={s}>{s}</IonSelectOption>)}
</IonSelect>
<IonSelect placeholder="Ordenar por" value={orden} onIonChange={(e) => aplicarOrden(e.detail.value)}>
<IonSelectOption value="ultima">Última lectura</IonSelectOption>
<IonSelectOption value="alfabetico">Alfabético</IonSelectOption>
<IonSelectOption value="calificacion">Calificación</IonSelectOption>
</IonSelect>
</IonItem>
</IonToolbar>
</IonHeader>
Expand Down Expand Up @@ -165,10 +194,15 @@ export default function CatalogoPage() {
<IonItem key={obra.id} button detail onClick={() => history.push(`/obra/${obra.id}`)}>
<IonLabel>
<h2>{obra.titulo}</h2>
<p className="muted">
{obra.tipo} · Cap. <span className="cap-actual">{capFmt(capitulo)}</span> · Última: {fechaCorta(ultima)}
{obra.calificacion ? ` · ⭐ ${obra.calificacion}` : ''}
{obra.autor ? ` · 👤 ${obra.autor}` : ''}
<p className="muted" style={{ display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap' }}>
<span>{obra.tipo} · Cap. <span className="cap-actual">{capFmt(capitulo)}</span> · Última: {fechaCorta(ultima)}</span>
{obra.calificacion ? (
<>
<span> · </span>
<StarRating value={obra.calificacion} readonly />
</>
) : null}
{obra.autor ? <span> · 👤 {obra.autor}</span> : null}
</p>
<div>
{obra.tags.slice(0, 3).map((t) => <IonChip key={t} outline>{t}</IonChip>)}
Expand Down
7 changes: 6 additions & 1 deletion src/ui/pages/ObraDetallePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -201,7 +202,11 @@ export default function ObraDetallePage() {
{obra.autor && <p style={{ margin: '4px 0' }}><strong>Autor:</strong> {obra.autor}</p>}
{obra.artista && <p style={{ margin: '4px 0' }}><strong>Artista:</strong> {obra.artista}</p>}
{obra.estadoPublicacion && <p style={{ margin: '4px 0' }}><strong>Publicación:</strong> <span style={{ textTransform: 'capitalize' }}>{obra.estadoPublicacion}</span></p>}
{obra.calificacion !== undefined && <p style={{ margin: '4px 0' }}><strong>Calificación:</strong> ⭐ {obra.calificacion}</p>}
{obra.calificacion !== undefined && (
<div style={{ margin: '4px 0', display: 'flex', alignItems: 'center', gap: '8px' }}>
<strong>Calificación:</strong> <StarRating value={obra.calificacion} readonly />
</div>
)}
</div>

{relacionadas.length > 0 && (
Expand Down
Loading