1
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
node_modules
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
Dockerfile*
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL=
|
||||
ARG BACKEND_INTERNAL_URL=http://backend:8000
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { api, DocumentItem } from "@/lib/api";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
|
||||
export default function DocumentPage({ params }: { params: { documentId: string } }) {
|
||||
const documentId = Number(params.documentId);
|
||||
const [document, setDocument] = useState<DocumentItem | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [savedContent, setSavedContent] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.document(documentId), api.markdown(documentId)]).then(([doc, markdown]) => {
|
||||
setDocument(doc); setContent(markdown); setSavedContent(markdown);
|
||||
}).catch((err) => setStatus(err.message));
|
||||
}, [documentId]);
|
||||
|
||||
async function save() {
|
||||
setStatus("Guardando...");
|
||||
await api.saveMarkdown(documentId, content);
|
||||
setSavedContent(content);
|
||||
setStatus("Guardado");
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setStatus("Copiado al portapapeles");
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Link href="/" className="text-sm text-blue-300">Volver al dashboard</Link>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{document?.title || "Documento"}</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">{dirty ? "Cambios sin guardar" : "Sin cambios pendientes"} {status && `- ${status}`}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={save} disabled={!dirty}>Guardar</Button>
|
||||
<Button className="bg-slate-700 hover:bg-slate-600" onClick={copy}>Copiar</Button>
|
||||
<a className="rounded-xl border border-white/10 px-4 py-2 hover:bg-white/10" href={api.downloadDocumentUrl(documentId)}>Descargar .md</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<Card className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 font-medium">Editor Markdown</div>
|
||||
<textarea value={content} onChange={(event) => setContent(event.target.value)} className="h-[72vh] w-full resize-none rounded-b-2xl bg-slate-950 p-4 font-mono text-sm leading-6 outline-none" />
|
||||
</Card>
|
||||
<Card className="overflow-auto">
|
||||
<div className="mb-4 font-medium">Vista previa</div>
|
||||
<article className="prose prose-invert prose-slate">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</article>
|
||||
</Card>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
textarea, input, button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Knowledge Station",
|
||||
description: "Procesa clases, documentos y transcripciones a Markdown.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es" className="dark">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, SettingsStatus, Subject } from "@/lib/api";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { Button, Card, Input, Label } from "@/components/ui";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [settings, setSettings] = useState<SettingsStatus | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const [subjectList, status] = await Promise.all([api.subjects(), api.settings()]);
|
||||
setSubjects(subjectList);
|
||||
setSettings(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "No se pudo conectar con la API");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function createSubject(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
await api.createSubject(name.trim());
|
||||
setName("");
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<section>
|
||||
<div className="mb-6">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-blue-300">Plataforma personal</p>
|
||||
<h1 className="mt-2 text-4xl font-bold tracking-tight">Materias</h1>
|
||||
<p className="mt-3 max-w-2xl text-slate-400">Organiza clases, PDFs, audios y videos por materia y semana. Todo termina en Markdown listo para ingesta en LLM.</p>
|
||||
</div>
|
||||
|
||||
{error && <Card className="mb-4 border-red-500/30 bg-red-500/10 text-red-200">{error}</Card>}
|
||||
{loading ? <Card>Cargando...</Card> : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{subjects.map((subject) => (
|
||||
<Link key={subject.id} href={`/subjects/${subject.id}`}>
|
||||
<Card className="transition hover:border-blue-400/40 hover:bg-blue-500/10">
|
||||
<div className="text-xl font-semibold">{subject.name}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{subject.slug}</div>
|
||||
<div className="mt-5 text-sm text-blue-300">Abrir materia</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{subjects.length === 0 && <Card className="md:col-span-2">No hay materias todavia. Crea la primera para empezar.</Card>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">Nueva materia</h2>
|
||||
<form className="mt-4 space-y-3" onSubmit={createSubject}>
|
||||
<Label>Nombre</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Ej. Comportamiento Organizacional" />
|
||||
<Button className="w-full" disabled={!name.trim()}>Crear materia</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">Servicios</h2>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span>Deepgram</span><span className={settings?.deepgram_configured ? "text-emerald-300" : "text-amber-300"}>{settings?.deepgram_configured ? "Configurado" : "Falta key"}</span></div>
|
||||
<div className="flex justify-between"><span>Mistral OCR</span><span className={settings?.mistral_configured ? "text-emerald-300" : "text-amber-300"}>{settings?.mistral_configured ? "Configurado" : "Falta key"}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, API_URL, SettingsStatus } from "@/lib/api";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsStatus | null>(null);
|
||||
const [apiOk, setApiOk] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.health(), api.settings()]).then(([, status]) => {
|
||||
setApiOk(true); setSettings(status);
|
||||
}).catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="text-4xl font-bold">Configuracion</h1>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold">Estado</h2>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<Row label="API" value={apiOk ? "Activa" : "No disponible"} ok={apiOk} />
|
||||
<Row label="Deepgram" value={settings?.deepgram_configured ? "Configurado" : "Falta DEEPGRAM_API_KEY"} ok={!!settings?.deepgram_configured} />
|
||||
<Row label="Mistral" value={settings?.mistral_configured ? "Configurado" : "Falta MISTRAL_API_KEY"} ok={!!settings?.mistral_configured} />
|
||||
</div>
|
||||
{error && <p className="mt-4 text-sm text-red-300">{error}</p>}
|
||||
</Card>
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold">Rutas</h2>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-300">
|
||||
<p><span className="text-slate-500">API:</span> {API_URL}</p>
|
||||
<p><span className="text-slate-500">Datos:</span> {settings?.data_dir || "-"}</p>
|
||||
<p><span className="text-slate-500">SQLite:</span> {settings?.database_path || "-"}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4">
|
||||
<h2 className="text-xl font-semibold">API Keys</h2>
|
||||
<p className="mt-3 text-slate-400">Configura las claves en el archivo <code className="rounded bg-slate-900 px-2 py-1">.env</code>. No se muestran ni editan desde la UI para evitar pegarlas accidentalmente en capturas o commits.</p>
|
||||
</Card>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||
return <div className="flex justify-between gap-4"><span>{label}</span><span className={ok ? "text-emerald-300" : "text-amber-300"}>{value}</span></div>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Shell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header className="border-b border-white/10 bg-slate-950/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-5 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-2xl bg-blue-600 font-bold">KS</div>
|
||||
<div>
|
||||
<div className="font-semibold tracking-tight">Knowledge Station</div>
|
||||
<div className="text-xs text-slate-400">Clases a Markdown para LLM</div>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<Link className="rounded-lg px-3 py-2 hover:bg-white/10" href="/">Materias</Link>
|
||||
<Link className="rounded-lg px-3 py-2 hover:bg-white/10" href="/settings">Configuracion</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-5 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={`rounded-2xl border border-white/10 bg-white/[0.04] p-5 shadow-xl shadow-black/20 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Button({ children, className = "", ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return <button className={`rounded-xl bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 ${className}`} {...props}>{children}</button>;
|
||||
}
|
||||
|
||||
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className="w-full rounded-xl border border-white/10 bg-slate-900 px-4 py-2 text-slate-100 outline-none ring-blue-500/30 focus:ring-4" {...props} />;
|
||||
}
|
||||
|
||||
export function Label({ children }: { children: React.ReactNode }) {
|
||||
return <label className="text-sm font-medium text-slate-300">{children}</label>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
completed: "bg-emerald-500/15 text-emerald-300",
|
||||
processing: "bg-blue-500/15 text-blue-300",
|
||||
queued: "bg-amber-500/15 text-amber-300",
|
||||
failed: "bg-red-500/15 text-red-300",
|
||||
stored: "bg-slate-500/15 text-slate-300",
|
||||
processed: "bg-emerald-500/15 text-emerald-300",
|
||||
};
|
||||
return <span className={`rounded-full px-2.5 py-1 text-xs font-medium ${map[status] || "bg-slate-500/15 text-slate-300"}`}>{status}</span>;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirmar",
|
||||
cancelLabel = "Cancelar",
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/75 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-slate-900 p-5 shadow-2xl shadow-black/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-red-500/15 text-red-200">!</div>
|
||||
<div>
|
||||
<h2 id="confirm-dialog-title" className="text-lg font-semibold text-white">{title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button className="rounded-xl border border-white/10 px-4 py-2 text-slate-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50" type="button" disabled={loading} onClick={onCancel}>{cancelLabel}</button>
|
||||
<Button className="bg-red-600 hover:bg-red-500" disabled={loading} onClick={onConfirm}>{loading ? "Eliminando..." : confirmLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8000";
|
||||
|
||||
export type Subject = { id: number; name: string; slug: string; created_at: string };
|
||||
export type Week = { id: number; subject_id: number; number: number; title?: string | null; created_at: string };
|
||||
export type Source = {
|
||||
id: number;
|
||||
week_id: number;
|
||||
original_name: string;
|
||||
stored_path: string;
|
||||
source_type: string;
|
||||
status: string;
|
||||
file_size?: number | null;
|
||||
sha256?: string | null;
|
||||
mime_type?: string | null;
|
||||
processor?: string | null;
|
||||
page_ranges?: string | null;
|
||||
processed_at?: string | null;
|
||||
document_id?: number | null;
|
||||
document_title?: string | null;
|
||||
document_markdown_path?: string | null;
|
||||
document_created_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
export type Job = { id: number; status: string; message: string; source_id?: number | null; document_id?: number | null; created_at: string; updated_at: string };
|
||||
export type DocumentItem = {
|
||||
id: number;
|
||||
source_id: number;
|
||||
title: string;
|
||||
markdown_path: string;
|
||||
source_original_name?: string | null;
|
||||
source_type?: string | null;
|
||||
source_status?: string | null;
|
||||
source_processor?: string | null;
|
||||
source_page_ranges?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
export type SettingsStatus = { mistral_configured: boolean; deepgram_configured: boolean; data_dir: string; database_path: string };
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
throw new Error(detail?.detail || `Error ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async health() {
|
||||
return parseResponse<{ status: string }>(await fetch(`${API_URL}/health`, { cache: "no-store" }));
|
||||
},
|
||||
async settings() {
|
||||
return parseResponse<SettingsStatus>(await fetch(`${API_URL}/settings/status`, { cache: "no-store" }));
|
||||
},
|
||||
async subjects() {
|
||||
return parseResponse<Subject[]>(await fetch(`${API_URL}/subjects`, { cache: "no-store" }));
|
||||
},
|
||||
async subject(id: number) {
|
||||
return parseResponse<Subject>(await fetch(`${API_URL}/subjects/${id}`, { cache: "no-store" }));
|
||||
},
|
||||
async createSubject(name: string) {
|
||||
return parseResponse<Subject>(await fetch(`${API_URL}/subjects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}));
|
||||
},
|
||||
async weeks(subjectId: number) {
|
||||
return parseResponse<Week[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, { cache: "no-store" }));
|
||||
},
|
||||
async week(subjectId: number, weekNumber: number) {
|
||||
return parseResponse<Week>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}`, { cache: "no-store" }));
|
||||
},
|
||||
async createWeek(subjectId: number, number: number, title: string) {
|
||||
return parseResponse<Week>(await fetch(`${API_URL}/subjects/${subjectId}/weeks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ number, title: title || null }),
|
||||
}));
|
||||
},
|
||||
async updateWeek(subjectId: number, currentNumber: number, number: number, title: string) {
|
||||
return parseResponse<Week>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${currentNumber}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ number, title: title || null }),
|
||||
}));
|
||||
},
|
||||
async sources(subjectId: number, weekNumber: number) {
|
||||
return parseResponse<Source[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/sources`, { cache: "no-store" }));
|
||||
},
|
||||
async jobs(subjectId: number, weekNumber: number) {
|
||||
return parseResponse<Job[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/jobs`, { cache: "no-store" }));
|
||||
},
|
||||
async documents(subjectId: number, weekNumber: number) {
|
||||
return parseResponse<DocumentItem[]>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/documents`, { cache: "no-store" }));
|
||||
},
|
||||
async deleteSource(sourceId: number) {
|
||||
return parseResponse<{ status: string }>(await fetch(`${API_URL}/sources/${sourceId}`, { method: "DELETE" }));
|
||||
},
|
||||
async deleteDocument(documentId: number) {
|
||||
return parseResponse<{ status: string }>(await fetch(`${API_URL}/documents/${documentId}`, { method: "DELETE" }));
|
||||
},
|
||||
async reprocessSource(sourceId: number, useOcr: boolean, pageRanges?: string) {
|
||||
const params = new URLSearchParams({ use_ocr: String(useOcr) });
|
||||
if (pageRanges?.trim()) params.set("page_ranges", pageRanges.trim());
|
||||
return parseResponse<{ source: Source; job: Job }>(await fetch(`${API_URL}/sources/${sourceId}/reprocess?${params.toString()}`, { method: "POST" }));
|
||||
},
|
||||
async upload(subjectId: number, weekNumber: number, file: File, useOcr: boolean, pageRanges?: string) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const params = new URLSearchParams({ use_ocr: String(useOcr) });
|
||||
if (pageRanges?.trim()) params.set("page_ranges", pageRanges.trim());
|
||||
return parseResponse<{ source: Source; job: Job }>(await fetch(`${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/files?${params.toString()}`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}));
|
||||
},
|
||||
async markdown(documentId: number) {
|
||||
const response = await fetch(`${API_URL}/documents/${documentId}/markdown`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Error ${response.status}`);
|
||||
return response.text();
|
||||
},
|
||||
async document(documentId: number) {
|
||||
return parseResponse<DocumentItem>(await fetch(`${API_URL}/documents/${documentId}`, { cache: "no-store" }));
|
||||
},
|
||||
async saveMarkdown(documentId: number, content: string) {
|
||||
return parseResponse<DocumentItem>(await fetch(`${API_URL}/documents/${documentId}/markdown`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
}));
|
||||
},
|
||||
downloadDocumentUrl(documentId: number) {
|
||||
return `${API_URL}/documents/${documentId}/download`;
|
||||
},
|
||||
exportWeekUrl(subjectId: number, weekNumber: number) {
|
||||
return `${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/export`;
|
||||
},
|
||||
exportWeekZipUrl(subjectId: number, weekNumber: number) {
|
||||
return `${API_URL}/subjects/${subjectId}/weeks/${weekNumber}/export.zip`;
|
||||
},
|
||||
};
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const backendUrl = process.env.BACKEND_INTERNAL_URL || "http://127.0.0.1:8000";
|
||||
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{ source: "/health", destination: `${backendUrl}/health` },
|
||||
{ source: "/settings/:path*", destination: `${backendUrl}/settings/:path*` },
|
||||
{ source: "/subjects/:path*", destination: `${backendUrl}/subjects/:path*` },
|
||||
{ source: "/sources/:path*", destination: `${backendUrl}/sources/:path*` },
|
||||
{ source: "/documents/:path*", destination: `${backendUrl}/documents/:path*` },
|
||||
{ source: "/jobs/:path*", destination: `${backendUrl}/jobs/:path*` },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+3106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "knowledge-station-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"next": "^14.2.23",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.10",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#0f172a",
|
||||
panel: "#111827",
|
||||
brand: "#2563eb",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user