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
8 changes: 8 additions & 0 deletions src/application/catalogo-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export interface Filtro {
tag?: string; // RF-014
estado?: EstadoObra; // RF-014
prioridad?: Prioridad; // RF-014
autor?: string;
artista?: string;
estadoPublicacion?: string; // EstadoPublicacion
calificacionMin?: number;
}

/** Casos de uso del catálogo (F-01, F-04). */
Expand Down Expand Up @@ -44,6 +48,10 @@ export class CatalogoService {
.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))
.filter((o) => (filtro.autor ? o.autor?.toLowerCase().includes(filtro.autor.toLowerCase()) : true))
.filter((o) => (filtro.artista ? o.artista?.toLowerCase().includes(filtro.artista.toLowerCase()) : true))
.filter((o) => (filtro.estadoPublicacion ? o.estadoPublicacion === filtro.estadoPublicacion : true))
.filter((o) => (filtro.calificacionMin !== undefined ? (o.calificacion ?? 0) >= filtro.calificacionMin : true))
.sort((a, b) => a.titulo.localeCompare(b.titulo, 'es'));
}
}
31 changes: 31 additions & 0 deletions src/domain/obra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
PRIORIDADES,
TIPOS_OBRA,
TipoObra,
EstadoPublicacion,
ESTADOS_PUBLICACION,
} from './types';

export interface Obra {
Expand All @@ -18,6 +20,11 @@ export interface Obra {
prioridad: Prioridad;
url?: string; // URL de lectura actual (cambiable)
notas?: string;
autor?: string;
artista?: string;
estadoPublicacion?: EstadoPublicacion;
calificacion?: number; // 0 a 5, paso 0.5
obrasRelacionadas?: string[]; // IDs de otras obras
creadaEn: string; // ISO 8601
actualizadaEn: string; // ISO 8601
}
Expand All @@ -31,6 +38,11 @@ export interface NuevaObra {
prioridad?: Prioridad;
url?: string; // URL de lectura actual
notas?: string;
autor?: string;
artista?: string;
estadoPublicacion?: EstadoPublicacion;
calificacion?: number;
obrasRelacionadas?: string[];
}

const norm = (s: string) => s.trim();
Expand Down Expand Up @@ -59,6 +71,15 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr
const prioridad = input.prioridad ?? 'media'; // RF-005: por defecto media
if (!PRIORIDADES.includes(prioridad)) throw new DomainError(`Prioridad inválida: ${prioridad} (RF-005).`);

if (input.calificacion !== undefined) {
if (input.calificacion < 0 || input.calificacion > 5) throw new DomainError(`La calificación debe estar entre 0 y 5.`);
if (input.calificacion % 0.5 !== 0) throw new DomainError(`La calificación debe ser múltiplo de 0.5.`);
}

if (input.estadoPublicacion !== undefined && !ESTADOS_PUBLICACION.includes(input.estadoPublicacion)) {
throw new DomainError(`Estado de publicación inválido: ${input.estadoPublicacion}.`);
}

const iso = ahora.toISOString();
const urlRaw = input.url?.trim();
return {
Expand All @@ -71,6 +92,11 @@ export function crearObra(input: NuevaObra, id: string, ahora = new Date()): Obr
prioridad,
url: urlRaw || undefined,
notas: input.notas?.trim() || undefined,
autor: input.autor?.trim() || undefined,
artista: input.artista?.trim() || undefined,
estadoPublicacion: input.estadoPublicacion,
calificacion: input.calificacion,
obrasRelacionadas: dedup(input.obrasRelacionadas ?? []),
creadaEn: iso,
actualizadaEn: iso,
};
Expand All @@ -88,6 +114,11 @@ export function editarObra(obra: Obra, cambios: Partial<NuevaObra>, ahora = new
prioridad: cambios.prioridad ?? obra.prioridad,
url: cambios.url !== undefined ? cambios.url : obra.url,
notas: cambios.notas ?? obra.notas,
autor: cambios.autor !== undefined ? cambios.autor : obra.autor,
artista: cambios.artista !== undefined ? cambios.artista : obra.artista,
estadoPublicacion: cambios.estadoPublicacion !== undefined ? cambios.estadoPublicacion : obra.estadoPublicacion,
calificacion: cambios.calificacion !== undefined ? cambios.calificacion : obra.calificacion,
obrasRelacionadas: cambios.obrasRelacionadas ?? obra.obrasRelacionadas,
},
obra.id,
ahora,
Expand Down
3 changes: 3 additions & 0 deletions src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const PRIORIDADES: Prioridad[] = ['alta', 'media', 'baja'];

export type EstadoFuente = 'activa' | 'caida' | 'sin_verificar';

export type EstadoPublicacion = 'emision' | 'finalizado' | 'pausado' | 'cancelado';
export const ESTADOS_PUBLICACION: EstadoPublicacion[] = ['emision', 'finalizado', 'pausado', 'cancelado'];

/** Error de violación de una invariante de dominio. */
export class DomainError extends Error {
constructor(message: string) {
Expand Down
6 changes: 6 additions & 0 deletions src/infrastructure/persistence/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export class CapMarkDB extends Dexie {
fuentes: 'id, obraId, esPrincipal',
progreso: 'id, obraId, registradoEn',
});
// v3: nuevos campos opcionales autor, artista, estadoPublicacion, calificacion, obrasRelacionadas
this.version(3).stores({
obras: 'id, titulo, tipo, estado, prioridad, actualizadaEn',
fuentes: 'id, obraId, esPrincipal',
progreso: 'id, obraId, registradoEn',
});
}
}

Expand Down
78 changes: 75 additions & 3 deletions src/ui/components/ObraFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
import { codeSlash, createOutline } from 'ionicons/icons';
import { NuevaObra } from '@application/ports';
import { Obra } from '@domain/obra';
import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA } from '@domain/types';
import { ESTADOS_OBRA, PRIORIDADES, TIPOS_OBRA, ESTADOS_PUBLICACION } from '@domain/types';
import { container } from '@infrastructure/container';

interface Props {
isOpen: boolean;
Expand All @@ -28,6 +29,13 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [todasObras, setTodasObras] = useState<Obra[]>([]);

useEffect(() => {
if (isOpen) {
container.catalogo.buscar({}).then(setTodasObras);
}
}, [isOpen]);

useEffect(() => {
if (obra) {
Expand All @@ -38,13 +46,18 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
prioridad: obra.prioridad,
url: obra.url ?? '',
notas: obra.notas,
autor: obra.autor,
artista: obra.artista,
estadoPublicacion: obra.estadoPublicacion,
calificacion: obra.calificacion,
obrasRelacionadas: obra.obrasRelacionadas,
};
setForm(base);
setAlias(obra.nombresAlternativos.join(', '));
setTags(obra.tags.join(', '));
setUrl(obra.url ?? '');
setJsonText(JSON.stringify(
{ ...base, nombresAlternativos: obra.nombresAlternativos, tags: obra.tags },
{ ...base, nombresAlternativos: obra.nombresAlternativos, tags: obra.tags, obrasRelacionadas: obra.obrasRelacionadas },
null,
2,
));
Expand Down Expand Up @@ -99,14 +112,19 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
setJsonText(raw);
setJsonError(null);
try {
const parsed = JSON.parse(raw) as Partial<NuevaObra> & { nombresAlternativos?: string[]; tags?: string[] };
const parsed = JSON.parse(raw) as Partial<NuevaObra> & { nombresAlternativos?: string[]; tags?: string[]; obrasRelacionadas?: string[] };
setForm({
titulo: parsed.titulo ?? '',
tipo: parsed.tipo ?? 'manga',
estado: parsed.estado ?? 'pendiente',
prioridad: parsed.prioridad ?? 'media',
url: parsed.url ?? '',
notas: parsed.notas,
autor: parsed.autor,
artista: parsed.artista,
estadoPublicacion: parsed.estadoPublicacion,
calificacion: parsed.calificacion,
obrasRelacionadas: parsed.obrasRelacionadas,
});
setAlias((parsed.nombresAlternativos ?? []).join(', '));
setTags((parsed.tags ?? []).join(', '));
Expand Down Expand Up @@ -180,6 +198,14 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
</IonSelect>
</IonItem>

<IonItem>
<IonLabel>Estado Publicación</IonLabel>
<IonSelect value={form.estadoPublicacion} onIonChange={(e) => setField({ estadoPublicacion: e.detail.value || undefined })}>
<IonSelectOption value="">Desconocido</IonSelectOption>
{ESTADOS_PUBLICACION.map((s) => <IonSelectOption key={s} value={s}>{s}</IonSelectOption>)}
</IonSelect>
</IonItem>

<IonItem>
<IonLabel>Prioridad</IonLabel>
<IonSelect value={form.prioridad} onIonChange={(e) => setField({ prioridad: e.detail.value })}>
Expand Down Expand Up @@ -215,6 +241,52 @@ export default function ObraFormModal({ isOpen, obra, onClose, onSave }: Props)
/>
</IonItem>

<IonItem>
<IonLabel position="stacked">Autor</IonLabel>
<IonInput
value={form.autor ?? ''}
onIonInput={(e) => setField({ autor: e.detail.value ?? undefined })}
placeholder="Ej. Chugong"
/>
</IonItem>

<IonItem>
<IonLabel position="stacked">Artista</IonLabel>
<IonInput
value={form.artista ?? ''}
onIonInput={(e) => setField({ artista: e.detail.value ?? undefined })}
placeholder="Ej. DUBU"
/>
</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 });
}}
/>
</IonItem>

<IonItem>
<IonLabel>Obras Relacionadas</IonLabel>
<IonSelect
multiple={true}
value={form.obrasRelacionadas ?? []}
onIonChange={(e) => setField({ obrasRelacionadas: e.detail.value })}
>
{todasObras.filter(o => o.id !== obra?.id).map((o) => (
<IonSelectOption key={o.id} value={o.id}>{o.titulo}</IonSelectOption>
))}
</IonSelect>
</IonItem>

<IonItem>
<IonLabel position="stacked">Notas</IonLabel>
<IonTextarea
Expand Down
8 changes: 7 additions & 1 deletion src/ui/pages/CatalogoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 { ESTADOS_OBRA, PRIORIDADES, ESTADOS_PUBLICACION } from '@domain/types';
import { container } from '@infrastructure/container';
import ObraFormModal from '@ui/components/ObraFormModal';
import SyncPanel from '@ui/components/SyncPanel';
Expand Down Expand Up @@ -129,6 +129,10 @@ export default function CatalogoPage() {
<IonSelectOption value="">Todos</IonSelectOption>
{tags.map((t) => <IonSelectOption key={t} value={t}>{t}</IonSelectOption>)}
</IonSelect>
<IonSelect placeholder="Publicación" value={filtro.estadoPublicacion} onIonChange={(e) => aplicar({ estadoPublicacion: e.detail.value || undefined })}>
<IonSelectOption value="">Cualquiera</IonSelectOption>
{ESTADOS_PUBLICACION.map((s) => <IonSelectOption key={s} value={s}>{s}</IonSelectOption>)}
</IonSelect>
</IonItem>
</IonToolbar>
</IonHeader>
Expand Down Expand Up @@ -163,6 +167,8 @@ export default function CatalogoPage() {
<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>
<div>
{obra.tags.slice(0, 3).map((t) => <IonChip key={t} outline>{t}</IonChip>)}
Expand Down
31 changes: 30 additions & 1 deletion src/ui/pages/ObraDetallePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,20 @@ export default function ObraDetallePage() {
const [obra, setObra] = useState<Obra>();
const [fuentes, setFuentes] = useState<Fuente[]>([]);
const [historial, setHistorial] = useState<ProgresoEntry[]>([]);
const [relacionadas, setRelacionadas] = useState<Obra[]>([]);
const [editar, setEditar] = useState(false);

const cargar = async () => {
setObra(await container.catalogo.obtener(id));
const o = await container.catalogo.obtener(id);
setObra(o);
setFuentes(await container.fuentes.listar(id));
setHistorial(await container.progreso.historial(id));
if (o?.obrasRelacionadas?.length) {
const rels = await Promise.all(o.obrasRelacionadas.map((rid) => container.catalogo.obtener(rid)));
setRelacionadas(rels.filter(Boolean) as Obra[]);
} else {
setRelacionadas([]);
}
};
useIonViewWillEnter(() => { void cargar(); });

Expand Down Expand Up @@ -188,6 +196,27 @@ export default function ObraDetallePage() {
<p className="muted">También: {obra.nombresAlternativos.join(' · ')}</p>
)}
<div>{obra.tags.map((t) => <IonChip key={t} outline>{t}</IonChip>)}</div>

<div style={{ marginTop: 12, marginBottom: 12 }}>
{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>}
</div>

{relacionadas.length > 0 && (
<div style={{ marginTop: 12, marginBottom: 16 }}>
<IonLabel><strong>Adaptaciones / Relacionadas:</strong></IonLabel>
<div style={{ marginTop: 4 }}>
{relacionadas.map(rel => (
<IonButton key={rel.id} fill="outline" size="small" onClick={() => history.push(`/obra/${rel.id}`)}>
{rel.titulo}
</IonButton>
))}
</div>
</div>
)}

{obra.notas && <IonText><p>{obra.notas}</p></IonText>}

{/* Progreso (RF-011/012, RNF-002) */}
Expand Down
Loading