feat: add curriculum management admin dashboard with AI generation and draft approval workflows

This commit is contained in:
RaymondVerhoef
2026-05-24 23:09:58 +02:00
parent b07c4808a6
commit 967c68d27d
6 changed files with 186 additions and 6 deletions

View File

@@ -0,0 +1,21 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const sources = app.findCollectionByNameOrId("sources");
sources.fields.add(new Field({
"hidden": false,
"id": "json_progress",
"maxSize": 0,
"name": "progress",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}));
app.save(sources);
}, (app) => {
const sources = app.findCollectionByNameOrId("sources");
sources.fields.removeById("json_progress");
app.save(sources);
});

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
@@ -26,6 +27,9 @@ const CurriculumManager = () => {
const [generationReason, setGenerationReason] = useState('');
// Prevent tab close during long AI operations
useBeforeUnload(isGenerating || isEnriching);
const currentIsoWeek = (() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));

View File

@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react';
import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus, AlertTriangle, Trash2 } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline';
import { pb } from '../../lib/pb';
import * as db from '../../lib/db';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import Card from '../ui/Card';
import Button from '../ui/Button';
@@ -11,10 +14,31 @@ const UploadZone = ({ onUploadComplete }) => {
const [queue, setQueue] = useState([]);
const [processingId, setProcessingId] = useState(null);
const [rejectedNote, setRejectedNote] = useState(null);
const [orphanedSources, setOrphanedSources] = useState([]);
const fileInputRef = useRef(null);
const abortRef = useRef(null);
// ── Tab-close guard ─────────────────────────────────────────────────────────
useBeforeUnload(processingId !== null);
// ── Detect orphaned sources on mount ────────────────────────────────────────
useEffect(() => {
db.getOrphanedSources().then(setOrphanedSources);
}, []);
const handleResetOrphan = async (id) => {
await db.updateSourceStatus(id, 'failed', 'Interrupted — tab was closed during extraction');
setOrphanedSources((prev) => prev.filter((s) => s.id !== id));
if (onUploadComplete) onUploadComplete();
};
const handleDeleteOrphan = async (id) => {
await db.deleteSource(id);
setOrphanedSources((prev) => prev.filter((s) => s.id !== id));
if (onUploadComplete) onUploadComplete();
};
// ── Processing loop ────────────────────────────────────────────────────────
useEffect(() => {
@@ -28,10 +52,38 @@ const UploadZone = ({ onUploadComplete }) => {
const controller = new AbortController();
abortRef.current = controller;
// Poll for progress updates from PocketBase while processing
let progressInterval;
let lastSourceId = null;
next.file.text()
.then((text) => processSourceText(text, next.name, { signal: controller.signal }))
.then(async (text) => {
// Start processing — get source ID from the pipeline to poll progress
const resultPromise = processSourceText(text, next.name, { signal: controller.signal });
// Find the newly created source record to track its progress
const sources = await db.getSources();
const sourceRec = sources.find(s => s.name === next.name && s.status === 'processing');
if (sourceRec) {
lastSourceId = sourceRec.id;
progressInterval = setInterval(async () => {
try {
const updated = await pb.collection('sources').getOne(lastSourceId);
if (updated.progress) {
setQueue((q) => q.map((item) =>
item.id === next.id
? { ...item, progress: updated.progress }
: item
));
}
} catch { /* source may have been deleted */ }
}, 2000);
}
return resultPromise;
})
.then(() => {
setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item));
setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done', progress: null } : item));
if (onUploadComplete) onUploadComplete();
})
.catch((err) => {
@@ -45,11 +97,12 @@ const UploadZone = ({ onUploadComplete }) => {
setQueue((q) => q.map((item) =>
item.id === next.id
? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg }
? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg, progress: null }
: item
));
})
.finally(() => {
if (progressInterval) clearInterval(progressInterval);
abortRef.current = null;
setProcessingId(null);
});
@@ -62,7 +115,7 @@ const UploadZone = ({ onUploadComplete }) => {
let rejected = 0;
for (const file of fileList) {
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null });
accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null, progress: null });
} else {
rejected++;
}
@@ -109,6 +162,52 @@ const UploadZone = ({ onUploadComplete }) => {
return (
<div className="space-y-4">
{/* ─── Orphaned Sources Warning ─── */}
{orphanedSources.length > 0 && (
<Card className="border-2 border-amber-300 bg-amber-50/50">
<div className="flex items-start gap-3 mb-3">
<AlertTriangle size={20} className="text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected</p>
<p className="text-sm text-amber-700 mt-1">
{orphanedSources.length === 1
? 'A source extraction was interrupted (tab closed or browser crashed). You can delete it and re-upload the file.'
: `${orphanedSources.length} source extractions were interrupted. Delete and re-upload the files to retry.`}
</p>
</div>
</div>
<div className="space-y-2">
{orphanedSources.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-[var(--r-sm)] bg-white/60 text-sm">
<div className="flex-1">
<span className="font-medium">{s.name}</span>
{s.progress && (
<span className="text-amber-600 ml-2 text-xs">
(stopped at chunk {s.progress.current}/{s.progress.total})
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleResetOrphan(s.id)}
className="text-xs text-amber-700 hover:text-amber-900 underline"
>
Mark failed
</button>
<button
onClick={() => handleDeleteOrphan(s.id)}
className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
</Card>
)}
{/* ─── Drag & Drop Zone ─── */}
<Card
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-10 cursor-pointer ${
@@ -149,7 +248,11 @@ const UploadZone = ({ onUploadComplete }) => {
<StatusIcon status={item.status} />
<span className="flex-1 truncate text-fg">{item.name}</span>
<span className="text-xs text-fg-muted shrink-0">
{item.status === 'processing' && 'Extracting…'}
{item.status === 'processing' && (
item.progress
? `Chunk ${item.progress.current + 1}/${item.progress.total}`
: 'Starting…'
)}
{item.status === 'pending' && 'Pending'}
{item.status === 'done' && 'Done'}
{item.status === 'cancelled' && 'Cancelled'}

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
/**
* Prevent accidental tab/window closure while a long-running operation
* is in progress. Shows the browser's native "Leave site?" confirmation.
*
* @param {boolean} active — true while the operation is running
*/
export function useBeforeUnload(active) {
useEffect(() => {
if (!active) return;
const handler = (e) => {
e.preventDefault();
// Legacy browsers require returnValue to be set
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [active]);
}

View File

@@ -133,6 +133,24 @@ export async function updateSourceStatus(id, status, error = '') {
return pb.collection('sources').update(id, { status, error });
}
export async function updateSourceProgress(id, progress) {
return pb.collection('sources').update(id, { progress });
}
/**
* Find sources stuck in 'processing' status — likely orphaned by a tab close.
* Sources older than 5 minutes in 'processing' state are considered stale.
*/
export async function getOrphanedSources() {
try {
const all = await pb.collection('sources').getFullList({
filter: 'status="processing"',
});
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
return all.filter(s => new Date(s.updated || s.created).getTime() < fiveMinAgo);
} catch { return []; }
}
export async function deleteSource(id) {
return pb.collection('sources').delete(id);
}

View File

@@ -128,6 +128,9 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
const chunks = chunkText(textContent);
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
// Persist initial progress so other sessions/reloads can see it
await db.updateSourceProgress(sourceId, { current: 0, total: chunks.length, message: 'Starting extraction...' });
const existingTopics = await db.getTopics();
const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label }));
@@ -138,6 +141,14 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
// Update progress before each chunk
await db.updateSourceProgress(sourceId, {
current: i,
total: chunks.length,
message: `Extracting chunk ${i + 1} of ${chunks.length}...`,
});
const hint = buildKnownIdsHint(knownTopics);
const result = await callLLM({
task: 'extract.source',
@@ -168,6 +179,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
}
}
await db.updateSourceProgress(sourceId, { current: chunks.length, total: chunks.length, message: 'Merging results...' });
await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations });
await db.updateSourceStatus(sourceId, 'completed');