502 lines
25 KiB
TypeScript
502 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { ChangeEvent, DragEvent, useEffect, useMemo, useState } from "react";
|
|
import { api, DocumentItem, Job, Source, Subject, Week, uploadWithProgress } 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 };
|
|
|
|
type UploadItem = {
|
|
id: string;
|
|
name: string;
|
|
size: number;
|
|
progress: number;
|
|
status: "uploading" | "done" | "error";
|
|
error?: string;
|
|
};
|
|
|
|
const UPLOAD_CONCURRENCY = 3;
|
|
|
|
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 [uploadItems, setUploadItems] = useState<UploadItem[]>([]);
|
|
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]);
|
|
const uploadSummary = useMemo(() => {
|
|
if (!uploadItems.length) return null;
|
|
const finished = uploadItems.filter((item) => item.status !== "uploading").length;
|
|
const failed = uploadItems.filter((item) => item.status === "error").length;
|
|
const percent = Math.round(
|
|
uploadItems.reduce((acc, item) => acc + (item.status === "done" ? 100 : item.progress), 0) / uploadItems.length,
|
|
);
|
|
return { finished, failed, total: uploadItems.length, percent };
|
|
}, [uploadItems]);
|
|
|
|
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("");
|
|
const ranges = pageMode === "ranges" ? pageRanges : undefined;
|
|
const items: UploadItem[] = files.map((file, index) => ({
|
|
id: `${Date.now()}-${index}-${file.name}`,
|
|
name: file.name,
|
|
size: file.size,
|
|
progress: 0,
|
|
status: "uploading",
|
|
}));
|
|
setUploadItems(items);
|
|
const patchItem = (id: string, patch: Partial<UploadItem>) => {
|
|
setUploadItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
|
};
|
|
// Pool de subida: hasta UPLOAD_CONCURRENCY archivos en vuelo a la vez,
|
|
// y un archivo fallido no interrumpe el resto de la tanda.
|
|
const failures: string[] = [];
|
|
let cursor = 0;
|
|
async function runUploadQueue() {
|
|
while (cursor < files.length) {
|
|
const index = cursor++;
|
|
const file = files[index];
|
|
const item = items[index];
|
|
try {
|
|
await uploadWithProgress(
|
|
subjectId,
|
|
weekNumber,
|
|
file,
|
|
isImageFile(file) || pdfMethod === "mistral_ocr",
|
|
(progress) => patchItem(item.id, { progress: Math.round((progress.loaded / progress.total) * 100) }),
|
|
ranges,
|
|
);
|
|
patchItem(item.id, { status: "done", progress: 100 });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "No se pudo subir archivo";
|
|
failures.push(`${file.name}: ${message}`);
|
|
patchItem(item.id, { status: "error", error: message });
|
|
}
|
|
}
|
|
}
|
|
const loadErrors: string[] = [];
|
|
try {
|
|
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => runUploadQueue()));
|
|
await load();
|
|
} catch (err) {
|
|
loadErrors.push(err instanceof Error ? err.message : "No se pudo completar la subida");
|
|
} finally {
|
|
setUploading(false);
|
|
setDragActive(false);
|
|
}
|
|
const problems = [...failures, ...loadErrors];
|
|
if (problems.length) {
|
|
setError(problems.join("\n"));
|
|
} else {
|
|
// Ocultar el panel unos segundos despues de terminar si no hay fallos.
|
|
window.setTimeout(() => {
|
|
setUploadItems((prev) => (prev.some((item) => item.status === "uploading") ? prev : []));
|
|
}, 6000);
|
|
}
|
|
}
|
|
|
|
function isPdfFile(file: File) {
|
|
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
|
}
|
|
|
|
function isImageFile(file: File) {
|
|
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(file.name);
|
|
}
|
|
|
|
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 isPdf = source.source_type === "pdf";
|
|
const ranges = isPdf && pageMode === "ranges" ? pageRanges : undefined;
|
|
const useOcr = isPdf ? pdfMethod === "mistral_ocr" : source.source_type === "image";
|
|
await api.reprocessSource(source.id, useOcr, 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="whitespace-pre-wrap border-red-500/30 bg-red-500/10 text-red-200 [overflow-wrap:anywhere]">{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, imagenes, 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} accept=".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif,.bmp,.tif,.tiff,.mp3,.wav,.m4a,.ogg,.flac,.webm,.mp4,.mov,.mkv,.avi" />
|
|
</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, imagenes (OCR automatico), audio y video.</p>
|
|
{uploading && (
|
|
<p className="mt-4 flex items-center justify-center gap-2 text-sm text-blue-300">
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-blue-400 border-t-transparent" aria-hidden="true" />
|
|
Subiendo archivos...
|
|
</p>
|
|
)}
|
|
</div>
|
|
{uploadItems.length > 0 && uploadSummary && (
|
|
<div className="mt-5 rounded-2xl border border-blue-400/30 bg-slate-950/60 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
|
{uploading ? (
|
|
<>
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-blue-400 border-t-transparent" aria-hidden="true" />
|
|
<span>Subiendo archivos · {uploadSummary.finished}/{uploadSummary.total} completados</span>
|
|
</>
|
|
) : (
|
|
<span className={uploadSummary.failed ? "text-amber-300" : "text-emerald-300"}>
|
|
Subida terminada: {uploadSummary.total - uploadSummary.failed} de {uploadSummary.total} correctos
|
|
</span>
|
|
)}
|
|
<span className="text-slate-400">{uploadSummary.percent}%</span>
|
|
</p>
|
|
{!uploading && (
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-white/10 px-2.5 py-1 text-xs text-slate-300 hover:bg-white/10"
|
|
onClick={() => setUploadItems([])}
|
|
>
|
|
Ocultar
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-white/10"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={uploadSummary.percent}
|
|
aria-label="Progreso general de subida"
|
|
>
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
|
uploading
|
|
? "bg-gradient-to-r from-blue-500 to-sky-400"
|
|
: uploadSummary.failed
|
|
? "bg-amber-500"
|
|
: "bg-emerald-500"
|
|
}`}
|
|
style={{ width: `${uploadSummary.percent}%` }}
|
|
/>
|
|
</div>
|
|
<ul className="mt-4 space-y-3">
|
|
{uploadItems.map((item) => (
|
|
<li className="text-sm" key={item.id}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{item.status === "uploading" && (
|
|
<span className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full border-2 border-blue-400 border-t-transparent" aria-hidden="true" />
|
|
)}
|
|
{item.status === "done" && <span className="shrink-0 text-emerald-400" aria-hidden="true">✓</span>}
|
|
{item.status === "error" && <span className="shrink-0 font-bold text-red-400" aria-hidden="true">✕</span>}
|
|
<span className="truncate">{item.name}</span>
|
|
<span className="shrink-0 text-xs text-slate-500">{formatFileSize(item.size)}</span>
|
|
</span>
|
|
<span className={`shrink-0 text-xs ${item.status === "error" ? "text-red-300" : item.status === "done" ? "text-emerald-300" : "text-blue-300"}`}>
|
|
{item.status === "error" ? "fallo" : `${Math.round(item.progress)}%`}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
|
{item.status === "uploading" ? (
|
|
<div className="relative h-full rounded-full bg-blue-500 transition-all duration-200 ease-out" style={{ width: `${Math.max(item.progress, 4)}%` }}>
|
|
<div className="absolute inset-0 overflow-hidden rounded-full">
|
|
<div className="h-full w-1/3 animate-bar-shimmer rounded-full bg-white/50" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={`h-full rounded-full ${item.status === "done" ? "bg-emerald-500" : "bg-red-500"}`} style={{ width: "100%" }} />
|
|
)}
|
|
</div>
|
|
{item.error && <p className="mt-1 text-xs text-red-300 [overflow-wrap:anywhere]">{item.error}</p>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</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="min-w-0 rounded-xl bg-slate-900 p-3 text-sm"><div className="flex items-start justify-between gap-2"><span>#{job.id}</span><StatusBadge status={job.status} /></div><p className="mt-2 whitespace-pre-wrap text-slate-400 [overflow-wrap:anywhere]">{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>
|
|
);
|
|
}
|