This commit is contained in:
urieljarethbusiness-cpu
2026-05-17 10:14:14 -06:00
parent d8773b2508
commit 64b3d15b90
61 changed files with 7612 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { api, Subject, Week } from "@/lib/api";
import { Shell } from "@/components/Shell";
import { Button, Card, Input, Label } from "@/components/ui";
export default function SubjectPage({ params }: { params: { subjectId: string } }) {
const subjectId = Number(params.subjectId);
const [subject, setSubject] = useState<Subject | null>(null);
const [weeks, setWeeks] = useState<Week[]>([]);
const [number, setNumber] = useState(1);
const [title, setTitle] = useState("");
const [editingWeek, setEditingWeek] = useState<Week | null>(null);
const [editNumber, setEditNumber] = useState(1);
const [editTitle, setEditTitle] = useState("");
const [error, setError] = useState("");
async function load() {
try {
const [subjectData, weekList] = await Promise.all([api.subject(subjectId), api.weeks(subjectId)]);
setSubject(subjectData);
setWeeks(weekList);
setNumber((weekList.at(-1)?.number || 0) + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo cargar la materia");
}
}
useEffect(() => { load(); }, [subjectId]);
async function createWeek(event: FormEvent) {
event.preventDefault();
try {
setError("");
await api.createWeek(subjectId, number, title);
setTitle("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo crear la semana");
}
}
function startEdit(week: Week) {
setEditingWeek(week);
setEditNumber(week.number);
setEditTitle(week.title || "");
setError("");
}
async function saveEdit(event: FormEvent) {
event.preventDefault();
if (!editingWeek) return;
try {
setError("");
await api.updateWeek(subjectId, editingWeek.number, editNumber, editTitle);
setEditingWeek(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo editar la semana");
}
}
return (
<Shell>
<Link href="/" className="text-sm text-blue-300">Volver a materias</Link>
<div className="mt-4 grid gap-6 lg:grid-cols-[1fr_360px]">
<section>
<h1 className="text-4xl font-bold tracking-tight">{subject?.name || "Materia"}</h1>
<p className="mt-2 text-slate-400">Divide el conocimiento por semanas y sube fuentes a cada una.</p>
{error && <Card className="mt-4 border-red-500/30 bg-red-500/10 text-red-200">{error}</Card>}
<div className="mt-6 grid gap-4 md:grid-cols-2">
{weeks.map((week) => (
<Card key={week.id} className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
<div className="text-2xl font-semibold">Semana {week.number}</div>
<div className="mt-2 text-slate-400">{week.title || "Sin titulo"}</div>
<div className="mt-5 flex flex-wrap gap-2 text-sm">
<Link className="rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-500" href={`/subjects/${subjectId}/weeks/${week.number}`}>Abrir semana</Link>
<button className="rounded-lg border border-white/10 px-3 py-2 text-slate-200 hover:bg-white/10" type="button" onClick={() => startEdit(week)}>Editar</button>
</div>
</Card>
))}
{weeks.length === 0 && <Card className="md:col-span-2">Todavia no hay semanas.</Card>}
</div>
</section>
<aside>
<Card>
{editingWeek ? (
<>
<h2 className="text-lg font-semibold">Editar semana</h2>
<p className="mt-1 text-sm text-slate-400">Cambia el numero o titulo. Si cambias el numero, se renombra la carpeta de la semana.</p>
<form className="mt-4 space-y-3" onSubmit={saveEdit}>
<Label>Numero</Label>
<Input type="number" min={1} value={editNumber} onChange={(event) => setEditNumber(Number(event.target.value))} />
<Label>Titulo</Label>
<Input value={editTitle} onChange={(event) => setEditTitle(event.target.value)} placeholder="Ej. Grupos y equipos" />
<div className="flex gap-2">
<Button className="flex-1">Guardar</Button>
<button className="flex-1 rounded-xl border border-white/10 px-4 py-2 hover:bg-white/10" type="button" onClick={() => setEditingWeek(null)}>Cancelar</button>
</div>
</form>
</>
) : (
<>
<h2 className="text-lg font-semibold">Nueva semana</h2>
<form className="mt-4 space-y-3" onSubmit={createWeek}>
<Label>Numero</Label>
<Input type="number" min={1} value={number} onChange={(event) => setNumber(Number(event.target.value))} />
<Label>Titulo opcional</Label>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Ej. Grupos y equipos" />
<Button className="w-full">Crear semana</Button>
</form>
</>
)}
</Card>
</aside>
</div>
</Shell>
);
}
@@ -0,0 +1,343 @@
"use client";
import Link from "next/link";
import { ChangeEvent, DragEvent, useEffect, useMemo, useState } from "react";
import { api, DocumentItem, Job, Source, Subject, Week } from "@/lib/api";
import { Shell } from "@/components/Shell";
import { Button, Card, ConfirmDialog, StatusBadge } from "@/components/ui";
type PendingDelete =
| { type: "document"; item: DocumentItem }
| { type: "source"; item: Source };
export default function WeekPage({ params }: { params: { subjectId: string; weekNumber: string } }) {
const subjectId = Number(params.subjectId);
const weekNumber = Number(params.weekNumber);
const [subject, setSubject] = useState<Subject | null>(null);
const [week, setWeek] = useState<Week | null>(null);
const [sources, setSources] = useState<Source[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [pdfMethod, setPdfMethod] = useState<"standard" | "mistral_ocr">("standard");
const [pageMode, setPageMode] = useState<"all" | "ranges">("all");
const [pageRanges, setPageRanges] = useState("");
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [reprocessingId, setReprocessingId] = useState<number | null>(null);
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState("");
const activeJobs = useMemo(() => jobs.some((job) => ["queued", "processing"].includes(job.status)), [jobs]);
const pendingPdfCount = useMemo(() => pendingFiles.filter(isPdfFile).length, [pendingFiles]);
async function load() {
const [subjectData, weekData, sourceList, jobList, documentList] = await Promise.all([
api.subject(subjectId), api.week(subjectId, weekNumber), api.sources(subjectId, weekNumber), api.jobs(subjectId, weekNumber), api.documents(subjectId, weekNumber),
]);
setSubject(subjectData); setWeek(weekData); setSources(sourceList); setJobs(jobList); setDocuments(documentList);
}
useEffect(() => { load().catch((err) => setError(err.message)); }, [subjectId, weekNumber]);
useEffect(() => {
if (!activeJobs) return;
const timer = window.setInterval(() => load().catch((err) => setError(err.message)), 2500);
return () => window.clearInterval(timer);
}, [activeJobs]);
async function uploadFileList(files: File[]) {
if (!files.length) return;
setUploading(true); setError("");
try {
const ranges = pageMode === "ranges" ? pageRanges : undefined;
for (const file of files) await api.upload(subjectId, weekNumber, file, pdfMethod === "mistral_ocr", ranges);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo subir archivo");
} finally {
setUploading(false);
setDragActive(false);
}
}
function isPdfFile(file: File) {
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
}
async function prepareFiles(files: File[]) {
if (!files.length) return;
setError("");
setDragActive(false);
if (files.some(isPdfFile)) {
setPendingFiles(files);
return;
}
await uploadFileList(files);
}
async function confirmPendingUpload() {
const files = pendingFiles;
setPendingFiles([]);
await uploadFileList(files);
}
async function uploadFiles(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files || []);
try {
await prepareFiles(files);
} finally {
event.target.value = "";
}
}
function handleDrag(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
if (uploading) return;
if (event.type === "dragenter" || event.type === "dragover") setDragActive(true);
if (event.type === "dragleave") setDragActive(false);
}
async function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
if (uploading) return;
const files = Array.from(event.dataTransfer.files || []);
await prepareFiles(files);
}
async function deleteDocument(document: DocumentItem) {
setDeletingId(`document-${document.id}`); setError("");
try {
await api.deleteDocument(document.id);
await load();
setPendingDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo eliminar el documento");
} finally {
setDeletingId(null);
}
}
async function deleteSource(source: Source) {
setDeletingId(`source-${source.id}`); setError("");
try {
await api.deleteSource(source.id);
await load();
setPendingDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo eliminar la fuente");
} finally {
setDeletingId(null);
}
}
async function reprocessSource(source: Source) {
setReprocessingId(source.id); setError("");
try {
const ranges = source.source_type === "pdf" && pageMode === "ranges" ? pageRanges : undefined;
await api.reprocessSource(source.id, source.source_type === "pdf" && pdfMethod === "mistral_ocr", ranges);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "No se pudo reprocesar la fuente");
} finally {
setReprocessingId(null);
}
}
function formatFileSize(size?: number | null) {
if (!size) return "tamano pendiente";
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function sourceProcessingLabel(source: Source) {
if (source.document_id) return `Genero: ${source.document_title}`;
if (["queued", "processing"].includes(source.status)) return "Procesamiento en curso";
if (source.status === "failed") return "Fallo el procesamiento";
return "Sin Markdown generado";
}
function confirmPendingDelete() {
if (!pendingDelete) return;
if (pendingDelete.type === "document") void deleteDocument(pendingDelete.item);
if (pendingDelete.type === "source") void deleteSource(pendingDelete.item);
}
const deleteDialog = pendingDelete ? {
title: pendingDelete.type === "document" ? "Eliminar Markdown procesado" : "Eliminar fuente",
description: pendingDelete.type === "document"
? `Se eliminara el archivo Markdown "${pendingDelete.item.title}". La fuente original quedara disponible.`
: `Se eliminara la fuente "${pendingDelete.item.original_name}" junto con sus trabajos y Markdown procesado asociado.`,
} : null;
return (
<Shell>
<Link href={`/subjects/${subjectId}`} className="text-sm text-blue-300">Volver a {subject?.name || "materia"}</Link>
<div className="mt-4 grid gap-6 lg:grid-cols-[1fr_380px]">
<section className="space-y-6">
<div>
<h1 className="text-4xl font-bold">Semana {weekNumber}</h1>
<p className="mt-2 text-slate-400">{week?.title || "Sube fuentes y genera Markdown procesado."}</p>
</div>
{error && <Card className="border-red-500/30 bg-red-500/10 text-red-200">{error}</Card>}
<Card>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Subir archivos</h2>
<p className="mt-1 text-sm text-slate-400">PDF, DOCX, TXT, Markdown, audio y video.</p>
</div>
<label className="cursor-pointer rounded-xl bg-blue-600 px-4 py-2 font-medium hover:bg-blue-500">
{uploading ? "Subiendo..." : "Seleccionar archivos"}
<input className="hidden" type="file" multiple onChange={uploadFiles} disabled={uploading} />
</label>
</div>
<div
className={`mt-5 rounded-2xl border-2 border-dashed p-8 text-center transition ${dragActive ? "border-blue-400 bg-blue-500/15" : "border-white/15 bg-slate-950/50 hover:border-blue-400/50"} ${uploading ? "opacity-70" : ""}`}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-blue-500/15 text-2xl"></div>
<p className="mt-4 font-medium">Arrastra archivos aqui</p>
<p className="mt-2 text-sm text-slate-400">Puedes soltar varios archivos a la vez. Se procesaran en esta semana.</p>
<p className="mt-3 text-xs text-slate-500">Soporta PDF, DOCX, TXT, Markdown, audio y video.</p>
{uploading && <p className="mt-4 text-sm text-blue-300">Subiendo archivos...</p>}
</div>
{pendingFiles.length > 0 && (
<div className="mt-5 rounded-2xl border border-blue-400/40 bg-blue-500/10 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-medium">Configurar PDFs antes de subir</h3>
<p className="mt-1 text-sm text-slate-300">
Detecte {pendingPdfCount} PDF en {pendingFiles.length} archivo(s). Elige como procesarlos antes de continuar.
</p>
</div>
<button className="rounded-lg border border-white/10 px-3 py-1.5 text-sm hover:bg-white/10" type="button" onClick={() => setPendingFiles([])}>Cancelar</button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-300">Metodo de procesamiento</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm outline-none ring-blue-500/30 focus:ring-4"
value={pdfMethod}
onChange={(event) => setPdfMethod(event.target.value as "standard" | "mistral_ocr")}
>
<option value="standard">Extraccion estandar de texto</option>
<option value="mistral_ocr">Mistral OCR (IA)</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-slate-300">Paginas a procesar</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm outline-none ring-blue-500/30 focus:ring-4"
value={pageMode}
onChange={(event) => setPageMode(event.target.value as "all" | "ranges")}
>
<option value="all">Todas las paginas</option>
<option value="ranges">Rangos especificos</option>
</select>
</div>
</div>
{pageMode === "ranges" && (
<div className="mt-4">
<label className="text-sm font-medium text-slate-300">Rangos</label>
<input
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm outline-none ring-blue-500/30 focus:ring-4"
value={pageRanges}
onChange={(event) => setPageRanges(event.target.value)}
placeholder="Ej: 1-5, 8, 10-12"
/>
<p className="mt-2 text-xs text-slate-500">Separa paginas o rangos con comas. Esta seleccion se aplicara a los PDFs pendientes.</p>
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={confirmPendingUpload} disabled={uploading || (pageMode === "ranges" && !pageRanges.trim())}>
{uploading ? "Subiendo..." : "Procesar y subir archivos"}
</Button>
<p className="self-center text-xs text-slate-400">Los archivos que no sean PDF se subiran junto con esta tanda.</p>
</div>
</div>
)}
</Card>
<Card>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-semibold">Documentos Markdown</h2>
<div className="flex flex-wrap gap-2">
<a className="rounded-xl border border-white/10 px-3 py-2 text-sm hover:bg-white/10" href={api.exportWeekUrl(subjectId, weekNumber)}>Exportar semana (.md unico)</a>
<a className="rounded-xl border border-white/10 px-3 py-2 text-sm hover:bg-white/10" href={api.exportWeekZipUrl(subjectId, weekNumber)}>Exportar documentos separados (.zip)</a>
</div>
</div>
<div className="space-y-3">
{documents.map((doc) => (
<div key={doc.id} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 p-4 hover:bg-white/5">
<Link href={`/documents/${doc.id}`} className="min-w-0 flex-1">
<div className="font-medium">{doc.title}</div>
<div className="mt-1 text-xs text-slate-400">Fuente: {doc.source_original_name || `#${doc.source_id}`}</div>
<div className="mt-1 truncate text-xs text-slate-500">{doc.markdown_path}</div>
</Link>
<Button
className="bg-red-600 px-3 py-1.5 text-sm hover:bg-red-500"
disabled={deletingId === `document-${doc.id}`}
onClick={() => setPendingDelete({ type: "document", item: doc })}
>
{deletingId === `document-${doc.id}` ? "Eliminando..." : "Eliminar"}
</Button>
</div>
))}
{documents.length === 0 && <p className="text-slate-400">Aun no hay documentos procesados.</p>}
</div>
</Card>
</section>
<aside className="space-y-4">
<Card>
<h2 className="text-lg font-semibold">Trabajos</h2>
<div className="mt-4 space-y-3">
{jobs.map((job) => <div key={job.id} className="rounded-xl bg-slate-900 p-3 text-sm"><div className="flex justify-between"><span>#{job.id}</span><StatusBadge status={job.status} /></div><p className="mt-2 text-slate-400">{job.message}</p></div>)}
{jobs.length === 0 && <p className="text-sm text-slate-400">Sin trabajos.</p>}
</div>
</Card>
<Card>
<h2 className="text-lg font-semibold">Fuentes</h2>
<div className="mt-4 space-y-3">
{sources.map((source) => {
const busy = ["queued", "processing"].includes(source.status);
return (
<div key={source.id} className="rounded-xl bg-slate-900 p-3 text-sm">
<div className="flex items-start justify-between gap-2"><span className="truncate">{source.original_name}</span><StatusBadge status={source.status} /></div>
<div className="mt-2 space-y-1 text-xs text-slate-500">
<p className={source.document_id ? "text-emerald-300" : source.status === "failed" ? "text-red-300" : "text-amber-300"}>{sourceProcessingLabel(source)}</p>
<p>{source.source_type} · {formatFileSize(source.file_size)}{source.processor ? ` · ${source.processor}` : ""}</p>
{source.sha256 && <p className="truncate">sha256: {source.sha256.slice(0, 12)}...</p>}
{source.page_ranges && <p>paginas: {source.page_ranges}</p>}
</div>
<div className="mt-3 flex flex-wrap justify-end gap-2">
{source.document_id && <Link className="rounded-lg border border-emerald-500/30 px-2 py-1 text-xs text-emerald-200 hover:bg-emerald-500/10" href={`/documents/${source.document_id}`}>Ver Markdown</Link>}
<button className="rounded-lg border border-white/10 px-2 py-1 text-xs text-slate-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50" disabled={busy || reprocessingId === source.id} onClick={() => reprocessSource(source)}>{reprocessingId === source.id ? "En cola..." : "Reprocesar"}</button>
<button className="rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-200 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-50" disabled={deletingId === `source-${source.id}` || busy} onClick={() => setPendingDelete({ type: "source", item: source })}>{deletingId === `source-${source.id}` ? "Eliminando..." : "Eliminar"}</button>
</div>
</div>
);
})}
{sources.length === 0 && <p className="text-sm text-slate-400">Sin fuentes.</p>}
</div>
</Card>
</aside>
</div>
<ConfirmDialog
open={Boolean(deleteDialog)}
title={deleteDialog?.title || "Confirmar accion"}
description={deleteDialog?.description || ""}
confirmLabel="Eliminar"
loading={Boolean(deletingId)}
onConfirm={confirmPendingDelete}
onCancel={() => setPendingDelete(null)}
/>
</Shell>
);
}