feat(import): admin UI at /admin/import, dev server, extraction cleanup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:20:40 +01:00
co-authored by Claude Opus 5.5
parent 9f96e293e1
commit 90a477691b
5 changed files with 246 additions and 3 deletions
+23
View File
@@ -0,0 +1,23 @@
// Dev server for the import UI while real sign-in (learner package) is not merged: everyone is the owner.
// Usage: STC_PORT=3462 STC_DATA_DIR=/tmp/stc-import-data STC_PYTHON=… bun packages/feature-import/dev/serve.ts
import { resolve } from "node:path";
import type { AuthService, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue, loadConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import indexHtml from "../../../apps/web/index.html";
import { importFeature } from "../src";
const config = loadConfig();
config.courseDirs.push(resolve(config.dataDir, "courses"));
const db = await connect(config.databaseUrl);
await migrate(db);
const auth: AuthService = { userFromRequest: async () => ({ id: "dev", email: "dev@localhost", name: "Dev owner", roles: ["owner"], groups: [] }) };
const ctx = { config, db, courses: await createCourseRepository(config.courseDirs), auth } as ServerContext;
const feature = importFeature(ctx);
const jobs = createJobQueue(db, feature.jobs!);
ctx.jobs = jobs;
const app = createApp(ctx, [feature]);
jobs.start();
const server = Bun.serve({ port: config.port, routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, fetch: (req) => app.fetch(req) });
console.log(`import dev server on ${server.url}`);
@@ -1,4 +1,4 @@
import { mkdir, rename } from "node:fs/promises";
import { mkdir, rename, rm } from "node:fs/promises";
import { basename, dirname, join, posix } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
@@ -46,7 +46,7 @@ export async function importPacktVideo(d: Deps, payload: unknown, jc: JobContext
used.add(entry);
}
await jc.reportProgress(0.05 + 0.65 * (done / total), `Copying section ${sec.number}: ${sec.title}`);
const tmp = join(stage, "x");
const tmp = join(stage, ".extract");
await src.extract(entries.map((e) => e.entry), tmp, jc.signal);
const lessons: any[] = [];
for (const { clip, entry } of entries) {
@@ -58,6 +58,7 @@ export async function importPacktVideo(d: Deps, payload: unknown, jc: JobContext
lessons.push({ id: `${mid}-l${clip.number}`, title: clip.title, type: "video", file: rel, ...(duration ? { duration } : {}) });
done++;
}
await rm(tmp, { recursive: true, force: true });
modules.push({ id: mid, title: sec.title.replace(/^Section\s+\d+\s*[:.-]\s*/i, ""), lessons });
}
const unused = src.entries.filter((e) => /\.(mp4|webm|m4v|mov)$/i.test(e) && !used.has(e));
+183 -1
View File
@@ -1 +1,183 @@
export default function ImportPage() { return <p>Import</p>; }
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Pill, ProgressBar, SegmentedToggle } from "@stc/ui";
import "./import.css";
type Kind = "packt-video" | "code-repo" | "pdf-deck" | "video-folder";
interface Job { id: string; kind: string; status: "queued" | "running" | "done" | "failed"; progress: number; message: string | null; payload: any; result: any; error: string | null; createdAt: string }
interface CourseInfo { id: string; title: string; modules: { id: string; title: string; lessons: { id: string; title: string }[] }[] }
interface Info { transcribe: boolean; coursesDir: string; coursesDirServed: boolean }
const KINDS: { value: Kind; label: string; hint: string; placeholder: string }[] = [
{ value: "packt-video", label: "Packt video zip", hint: "A video package whose index.html lists the sections and clips.", placeholder: "/data/downloads/video_zip.zip" },
{ value: "code-repo", label: "Code repository", hint: "A zip, a folder or a git URL. Folders such as S08 - Name attach to the matching section.", placeholder: "/data/downloads/course-main.zip or https://github.com/…" },
{ value: "pdf-deck", label: "Slide PDF", hint: "Becomes a deck on a lesson or section. Its text is extracted for search.", placeholder: "/data/downloads/slides.pdf" },
{ value: "video-folder", label: "Folder of videos", hint: "One lesson per video file, in natural file-name order.", placeholder: "/mnt/videos/my-course" },
];
const LABEL: Record<string, string> = { "import.packt-video": "Packt video", "import.code-repo": "Code repository", "import.pdf-deck": "Slide PDF", "import.video-folder": "Video folder", "import.transcribe": "Transcription" };
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
const body = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(body.error ?? `HTTP ${r.status}`), { status: r.status });
return body as T;
}
function CoverPicker({ courseId }: { courseId: string }) {
const [data, setData] = useState<{ current: string | null; candidates: string[] } | null>(null);
const [version, setVersion] = useState(0);
useEffect(() => { api<typeof data>(`/api/import/covers/${courseId}`).then(setData).catch(() => setData(null)); }, [courseId, version]);
if (!data || data.candidates.length === 0) return null;
return (
<details>
<summary>Cover candidates</summary>
<div className="imp-covers">
{data.candidates.map((name) => (
<div className="imp-cand" key={name}>
<img src={`/api/import/covers/${courseId}/${name}`} alt={`Cover candidate ${name}`} loading="lazy" />
<Pill onClick={async () => { await api(`/api/import/covers/${courseId}`, { method: "POST", body: JSON.stringify({ name }) }); setVersion((v) => v + 1); }}>Use as cover</Pill>
</div>
))}
</div>
</details>
);
}
function JobRow({ job }: { job: Job }) {
const src = job.payload?.path ? String(job.payload.path).split("/").filter(Boolean).pop() : job.payload?.courseId;
const result = job.result;
const warnings: string[] = result?.warnings ?? [];
return (
<li className="imp-job">
<div className="imp-job-top">
<span className="imp-job-kind">{LABEL[job.kind] ?? job.kind}</span>
<span className="imp-job-src" title={job.payload?.path}>{src}</span>
<span className="imp-status" data-s={job.status}>{job.status}</span>
</div>
<div className="imp-job-bar">
<ProgressBar value={job.status === "done" ? 1 : job.progress} label={`${LABEL[job.kind] ?? job.kind} progress`} />
<span className="imp-job-msg">{job.message}</span>
</div>
{job.error ? <div className="imp-err" role="alert">{job.error}</div> : null}
{job.status === "done" && result?.skipped ? <div className="imp-job-msg">Skipped: {result.reason}</div> : null}
{job.status === "done" && result?.courseId && !result.skipped ? (
<div className="imp-job-msg">Course <a href={`/c/${result.courseId}`}>{result.courseId}</a>, restricted to the owner until you change its visibility.</div>
) : null}
{warnings.length ? (
<details><summary>{warnings.length} warning{warnings.length > 1 ? "s" : ""}</summary><ul className="warn">{warnings.map((w, i) => <li key={i}>{w}</li>)}</ul></details>
) : null}
{job.status === "done" && job.kind !== "import.transcribe" && result?.courseId ? <CoverPicker courseId={result.courseId} /> : null}
</li>
);
}
export default function ImportPage() {
const [info, setInfo] = useState<Info | null>(null);
const [courses, setCourses] = useState<CourseInfo[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [denied, setDenied] = useState<string | null>(null);
const [kind, setKind] = useState<Kind>("packt-video");
const [path, setPath] = useState("");
const [title, setTitle] = useState("");
const [courseId, setCourseId] = useState("");
const [attachTo, setAttachTo] = useState("");
const [transcribe, setTranscribe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
setJobs((await api<Job[]>("/api/import/jobs")).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
setCourses(await api<CourseInfo[]>("/api/import/courses"));
} catch (e: any) {
if (e.status === 401 || e.status === 403) setDenied(e.message);
}
}, []);
useEffect(() => {
api<Info>("/api/import/info").then(setInfo).catch((e) => { if (e.status === 401 || e.status === 403) setDenied(e.message); });
void refresh();
const t = setInterval(refresh, 2000);
return () => clearInterval(t);
}, [refresh]);
const spec = KINDS.find((k) => k.value === kind)!;
const needsCourse = kind === "code-repo" || kind === "pdf-deck";
const course = courses.find((c) => c.id === courseId);
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await api("/api/import/jobs", {
method: "POST",
body: JSON.stringify({ kind, path, options: { title, courseId: needsCourse ? courseId : undefined, attachTo: kind === "pdf-deck" ? attachTo : undefined, transcribe: transcribe || undefined } }),
});
setPath(""); setTitle("");
await refresh();
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (denied) {
return <div className="imp"><h1 className="imp-h">Import</h1><p className="imp-sub" role="alert">{denied === "Sign in required" ? "Sign in as the owner to import courses." : "Only the owner can import courses."}</p></div>;
}
return (
<div className="imp">
<h1 className="imp-h">Import</h1>
<p className="imp-sub">Turn material you own into a course. Originals are only read; the new course is restricted until you change its visibility.</p>
{info && !info.coursesDirServed ? (
<p className="imp-note" role="status">The server is not scanning <code>{info.coursesDir}</code>. Add it to <code>STC_COURSE_DIRS</code> to see imported courses.</p>
) : null}
<form className="imp-form" onSubmit={submit}>
<SegmentedToggle label="Source type" options={KINDS.map((k) => ({ value: k.value, label: k.label }))} value={kind} onChange={(v) => setKind(v as Kind)} />
<label className="imp-field">Path on the server
<input type="text" className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder={spec.placeholder} required />
<span className="imp-hint">{spec.hint}</span>
</label>
{!needsCourse ? (
<label className="imp-field">Course title (optional)
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Taken from the source when empty" />
</label>
) : (
<div className="imp-row">
<label className="imp-field">Add to course
<select value={courseId} onChange={(e) => { setCourseId(e.target.value); setAttachTo(""); }} required>
<option value="">Choose an imported course</option>
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
</label>
{kind === "pdf-deck" ? (
<label className="imp-field">Attach deck to
<select value={attachTo} onChange={(e) => setAttachTo(e.target.value)} required disabled={!course}>
<option value="">Choose a section or lesson</option>
{course?.modules.map((m) => (
<optgroup key={m.id} label={m.title}>
<option value={m.id}>Whole section: {m.title}</option>
{m.lessons.map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
</optgroup>
))}
</select>
</label>
) : null}
</div>
)}
{info?.transcribe && (kind === "packt-video" || kind === "video-folder") ? (
<label className="imp-check"><input type="checkbox" checked={transcribe} onChange={(e) => setTranscribe(e.target.checked)} />Transcribe the videos afterwards</label>
) : null}
<div className="imp-actions">
<Pill variant="primary" type="submit" disabled={busy || !path}>Start import</Pill>
{error ? <span className="imp-err" role="alert">{error}</span> : null}
</div>
</form>
<h2 className="imp-sec">Jobs</h2>
{jobs.length === 0 ? <p className="imp-empty">No imports yet.</p> : <ul className="imp-jobs">{jobs.map((j) => <JobRow key={j.id} job={j} />)}</ul>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
.imp { max-width: 880px; margin: 0 auto; padding-bottom: 80px; }
.imp-h { font: 600 24px/1.2 var(--sans); color: var(--head); margin: 0 0 4px; }
.imp-sub { margin: 0 0 28px; color: var(--muted); font-size: 13.5px; }
.imp-sec { font: 600 14px var(--sans); color: var(--muted); margin: 34px 0 10px; }
.imp-note { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--line2); border-radius: 8px; background: var(--side); font-size: 13px; color: var(--muted); }
.imp-note code, .imp-hint code { font-size: 12px; color: var(--text); }
.imp-form { display: grid; gap: 16px; padding: 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--side); }
.imp-field { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
.imp-field input[type="text"], .imp-field select { height: 36px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--ground); color: var(--text); font-size: 13.5px; width: 100%; }
.imp-field input.mono { font-family: var(--mono); font-size: 12.5px; }
.imp-field input::placeholder { color: var(--faint); }
.imp-hint { font-size: 12px; color: var(--faint); }
.imp-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.imp-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
.imp-check input { accent-color: var(--accent); }
.imp-actions { display: flex; align-items: center; gap: 14px; }
.imp-err { color: var(--bad); font-size: 13px; }
.imp-jobs { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.imp-job { display: grid; gap: 8px; padding: 14px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
.imp-job-top { display: flex; align-items: baseline; gap: 12px; }
.imp-job-kind { font-weight: 600; color: var(--head); }
.imp-job-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 12.5px; }
.imp-status { font-size: 12px; font-weight: 500; color: var(--muted); }
.imp-status[data-s="done"] { color: var(--ok); }
.imp-status[data-s="failed"] { color: var(--bad); }
.imp-status[data-s="running"] { color: var(--accent); }
.imp-job-bar { display: flex; align-items: center; gap: 12px; }
.imp-job-msg { font-size: 12.5px; color: var(--faint); }
.imp-job details { font-size: 12.5px; color: var(--muted); }
.imp-job summary { cursor: pointer; }
.imp-job ul.warn { margin: 6px 0 0; padding-left: 18px; }
.imp-covers { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.imp-cand { display: grid; gap: 6px; justify-items: start; }
.imp-cand img { width: 160px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 6px; border: 1px solid var(--line2); background: var(--raised); }
.imp-cand[aria-current="true"] img { border-color: var(--accent); }
.imp-empty { color: var(--faint); font-size: 13px; padding: 14px 2px; }
@@ -26,6 +26,7 @@ test("imports a Packt zip into a restricted course with sections, titles and med
expect(env.deps.ctx.courses.resolvePath("docker-test", "media/video2_1.mp4")).toBeTruthy();
expect(m.cover).toBe("media/cover.jpg");
expect((await readdir(join(env.dataDir, "cache/covers/docker-test"))).sort()).toEqual(["frame-1.jpg", "frame-2.jpg", "frame-3.jpg"]);
expect((await readdir(c.root)).sort()).toEqual(["media", "stcourse.yaml"]); // no extraction leftovers
expect(await readdir(join(env.dataDir, "tmp"))).toEqual([]); // scratch removed
expect(Buffer.compare(await readFile(zip), before)).toBe(0); // original untouched
expect(env.progress.at(-1)![0]).toBe(1);