feat(studio): studio web UI (course list, manifest, lessons with live preview, decks, attachments, exercises, conflicts, sync) and dev entry

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:26:18 +01:00
co-authored by Claude Opus 5.5
parent 497ce91216
commit 0992df7257
18 changed files with 1403 additions and 5 deletions
+1
View File
@@ -2,3 +2,4 @@ import type { WebFeature } from "@stc/contracts";
// Phase 1 packages append one import line each and push into this list.
export const webFeatures: WebFeature[] = [];
import { studioWeb } from "../../../packages/feature-studio/src/web"; webFeatures.push(studioWeb);
+35
View File
@@ -0,0 +1,35 @@
/**
* Dev server for the studio until Phase 2 lets apps/server compose ctx-dependent features
* (docs/contract-requests/studio.md). Mirrors startServer, adds the studio feature and a dev-only user.
*
* STC_PORT=3463 STC_DATA_DIR=/tmp/stc-studio-data STC_DEV_USER=author bun packages/feature-studio/dev/serve.ts
*
* STC_DEV_USER: "author" | "owner" | "learner" | unset (anonymous). Never use this entry in production.
*/
import type { AuthUser, 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 { studioCourseDirs, studioFeature } from "../src";
const config = loadConfig();
if (process.env.NODE_ENV === "production") throw new Error("dev/serve.ts is for development only");
const role = process.env.STC_DEV_USER as AuthUser["roles"][number] | undefined;
const devUser: AuthUser | null = role ? { id: `dev-${role}`, name: `Dev ${role[0]!.toUpperCase()}${role.slice(1)}`, email: `${role}@dev.invalid`, roles: [role], groups: [] } : null;
const db = await connect(config.databaseUrl);
await migrate(db);
const courses = await createCourseRepository(studioCourseDirs(config));
const jobs = createJobQueue(db, {});
const ctx: ServerContext = { config, db, courses, jobs, auth: { userFromRequest: async () => devUser } };
const studio = studioFeature(ctx);
const app = createApp(ctx, [studio]);
jobs.start();
const server = Bun.serve({
port: config.port,
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
fetch: (req) => app.fetch(req),
development: { hmr: false, console: true },
});
console.log(`STCourses studio dev server on ${server.url} as ${devUser ? devUser.name : "anonymous"}`);
@@ -0,0 +1,163 @@
import { useState, type FormEvent } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { CourseCover, Pill } from "@stc/ui";
import { api, coursePath, uniqueId, type Diagnostic, type ManifestLesson } from "./api";
import { AccessMessage } from "./StudioHome";
import { clone, useWorkspace, type Workspace } from "./workspace";
import { CourseSettings } from "./editors/CourseSettings";
import { LessonEditor } from "./editors/LessonEditor";
import { AttachmentsEditor, DecksEditor } from "./editors/Media";
import { ConflictView, RawManifest, SyncPanel } from "./editors/GitPanels";
import { Field } from "./editors/common";
import "./studio.css";
type Notice = { kind: "ok" | "warn" | "bad"; text: string; files?: string[]; diagnostics?: Diagnostic[]; offerUpdate?: boolean };
export default function CourseStudio() {
const { courseId = "" } = useParams();
const { ws, readOnly, error } = useWorkspace(courseId);
if (error) return <div className="studio studio-page"><p className="chap"><Link to="/studio">Studio</Link></p>{error.status === 404 ? <div className="studio-note studio-note--bad"><strong>No such course.</strong></div> : <AccessMessage {...error} />}</div>;
if (readOnly) {
return (
<div className="studio studio-page">
<p className="chap"><Link to="/studio">Studio</Link></p>
<h1>{readOnly.title}</h1>
<div className="studio-note studio-note--warn" role="note"><strong>{readOnly.message}.</strong> It is read-only here; edit it in its own repository at <code>{readOnly.root}</code>.</div>
</div>
);
}
if (!ws) return <div className="studio studio-page"><p className="faint">Loading…</p></div>;
return <Workbench ws={ws} />;
}
function Workbench({ ws }: { ws: Workspace }) {
const [params, setParams] = useSearchParams();
const view = ws.state.status.merging ? "conflicts" : params.get("view") ?? "settings";
const lessonId = params.get("lesson");
const go = (v: string, lesson?: string) => {
if (ws.docDirty && !window.confirm("You have unsaved changes to the course structure. Leave them unsaved for now?")) return;
setParams(lesson ? { view: v, lesson } : { view: v });
};
const [notice, setNotice] = useState<Notice | null>(null);
const [busy, setBusy] = useState(false);
const st = ws.state.status;
const hasErrors = ws.state.manifestErrors.length > 0 || ws.state.diagnostics.some((d) => d.level === "error");
const publish = async () => {
setBusy(true);
const { status, data } = await api("POST", coursePath(ws.courseId, "/publish"));
setBusy(false);
if (status === 200) setNotice(data.published ? { kind: "ok", text: "Published. Learners now see this version." } : { kind: "ok", text: "Nothing new to publish." });
else if (data?.reason === "conflict") setNotice({ kind: "warn", text: "Not published: the published version was changed outside your draft, in the same places you edited. Nothing was overwritten. Update your draft, resolve the conflicts, then publish.", files: data.conflicts, offerUpdate: true });
else if (data?.reason === "dirty") setNotice({ kind: "warn", text: "Not published: someone has unsaved edits in the published folder on the server. Ask them to commit or discard them first.", files: data.files });
else if (data?.reason === "invalid") setNotice({ kind: "bad", text: "Not published: the draft has errors.", diagnostics: data.diagnostics });
else setNotice({ kind: "bad", text: data?.error ?? `Publish failed (${status})` });
await ws.reload();
};
const updateDraft = async () => {
setBusy(true);
const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "published" });
setBusy(false);
setNotice(status === 200 ? { kind: "ok", text: "Your draft now includes the latest published changes." } : status === 409 && data?.reason === "conflict" ? null : { kind: "bad", text: data?.message ?? data?.error });
await ws.reload();
};
return (
<div className="studio studio-ws">
<aside className="studio-aside" aria-label="Course outline">
<div className="studio-side-head" style={{ display: "flex", gap: 12 }}>
<CourseCover title={ws.doc.title} accent={ws.doc.accent} />
<div style={{ minWidth: 0 }}>
<h2>{ws.doc.title}</h2>
<div className="src"><Link to="/studio">Studio</Link> · <code>{ws.courseId}</code></div>
</div>
</div>
<ul className="studio-nav">
{[["settings", "Course settings"], ["decks", "Deck links"], ["attachments", "Attachments"], ["manifest", "stcourse.yaml"], ["sync", "Sync"]].map(([v, label]) => (
<li key={v}><button type="button" aria-current={view === v ? "page" : undefined} onClick={() => go(v!)}>{label}{v === "sync" && st.remote ? <span className="k">remote</span> : null}</button></li>
))}
{st.merging ? <li><button type="button" aria-current="page">Conflicts <span className="k">{st.conflicts.length}</span></button></li> : null}
</ul>
{ws.doc.modules.map((m) => (
<ul className="studio-nav" key={m.id} aria-label={m.title}>
<li className="mod">{m.title}</li>
{m.lessons.map((l) => (
<li key={l.id}>
<button type="button" aria-current={view === "lesson" && lessonId === l.id ? "page" : undefined} onClick={() => go("lesson", l.id)}>
<span>{l.title}</span><span className="k">{l.type === "text" ? "" : l.type}</span>
</button>
</li>
))}
<li><AddLesson ws={ws} moduleId={m.id} onAdded={(id) => setParams({ view: "lesson", lesson: id })} /></li>
</ul>
))}
</aside>
<div className="studio-content">
<div className="studio-bar" role="region" aria-label="Draft status">
<div className="where">
Editing your draft <b><code>{st.branch}</code></b>
{" · "}{st.ahead ? <b>{st.ahead} unpublished {st.ahead === 1 ? "change" : "changes"}</b> : "nothing unpublished"}
{st.behind ? <> · <b>{st.behind} new on published</b> since your draft</> : null}
{ws.docDirty ? <> · <b>unsaved structure changes</b></> : null}
</div>
{st.behind && !st.merging ? <Pill disabled={busy} onClick={updateDraft}>Update draft</Pill> : null}
<Pill variant="primary" disabled={busy || st.merging || !st.ahead || hasErrors} title={hasErrors ? "Fix the errors in the draft first" : undefined} onClick={publish}>Publish</Pill>
</div>
{notice ? (
<div className={`studio-note studio-note--${notice.kind}`} role="status">
{notice.text}
{notice.files?.length ? <ul className="studio-errors" style={{ color: "var(--text)" }}>{notice.files.map((f) => <li key={f}>{f}</li>)}</ul> : null}
{notice.diagnostics?.length ? <ul className="studio-errors">{notice.diagnostics.map((d, i) => <li key={i}>{d.lessonId ? `${d.lessonId}: ` : ""}{d.message}</li>)}</ul> : null}
{notice.offerUpdate ? <div className="studio-actions" style={{ marginTop: 8 }}><Pill onClick={updateDraft}>Update draft and resolve</Pill></div> : null}
</div>
) : null}
{view === "conflicts" ? <ConflictView ws={ws} />
: view === "lesson" && lessonId ? <LessonEditor key={lessonId} ws={ws} lessonId={lessonId} onGone={() => setParams({ view: "settings" })} />
: view === "decks" ? <DecksEditor ws={ws} />
: view === "attachments" ? <AttachmentsEditor ws={ws} />
: view === "manifest" ? <RawManifest ws={ws} />
: view === "sync" ? <SyncPanel ws={ws} />
: <CourseSettings ws={ws} />}
</div>
</div>
);
}
/** Adds a lesson: manifest entry plus its Markdown file, saved in one commit. */
function AddLesson({ ws, moduleId, onAdded }: { ws: Workspace; moduleId: string; onAdded: (id: string) => void }) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [type, setType] = useState<ManifestLesson["type"]>("text");
const [err, setErr] = useState<string | null>(null);
if (!open) return <button type="button" className="add" onClick={() => setOpen(true)}>+ Add lesson</button>;
const submit = async (e: FormEvent) => {
e.preventDefault();
const taken = new Set(ws.doc.modules.flatMap((m) => m.lessons.map((l) => l.id)));
const id = uniqueId(title, taken);
const doc = clone(ws.doc);
const lesson: ManifestLesson = type === "video"
? { id, title: title.trim(), type, file: `media/${id}.mp4`, transcript: `lessons/${id}.md` }
: { id, title: title.trim(), type, file: `lessons/${id}.md` };
doc.modules.find((m) => m.id === moduleId)!.lessons.push(lesson);
const r = await ws.save({ manifest: doc, files: [{ path: `lessons/${id}.md`, content: `# ${title.trim()}\n\n`, baseHash: null }], message: `Add lesson ${id}` });
if (!r.ok) return setErr(r.error ?? "A file with that name already exists");
setOpen(false); setTitle("");
onAdded(id);
};
return (
<form onSubmit={submit} style={{ display: "grid", gap: 8, padding: "6px 16px 10px" }}>
<Field label="Lesson title"><input type="text" autoFocus required value={title} onChange={(e) => setTitle(e.target.value)} /></Field>
<Field label="Type">
<select value={type} onChange={(e) => setType(e.target.value as ManifestLesson["type"])}>
<option value="text">Text</option><option value="video">Video</option><option value="exercise">Exercise</option>
</select>
</Field>
{err ? <span className="studio-errors" role="alert">{err}</span> : null}
<div className="studio-actions"><Pill type="submit" variant="primary" disabled={!title.trim()}>Add</Pill><Pill variant="ghost" onClick={() => setOpen(false)}>Cancel</Pill></div>
</form>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { CourseCover, Pill } from "@stc/ui";
import { api, slug } from "./api";
import "./studio.css";
interface CourseRow {
id: string; title: string; pitch: string | null; accent: string | null; managed: boolean; publishedStatus: "ok" | "error";
ahead?: number; behind?: number; merging?: boolean; remote?: string | null; message?: string;
}
export function AccessMessage({ status, error }: { status: number; error?: string }) {
if (status === 401) return <div className="studio-note studio-note--warn"><strong>Sign in to use the studio.</strong> The studio is for course authors and the owner.</div>;
if (status === 403) return <div className="studio-note studio-note--warn"><strong>The studio is for authors.</strong> Ask the owner for author access.</div>;
return <div className="studio-note studio-note--bad"><strong>Something went wrong.</strong> {error}</div>;
}
export default function StudioHome() {
const [rows, setRows] = useState<CourseRow[] | null>(null);
const [canCreate, setCanCreate] = useState(false);
const [err, setErr] = useState<{ status: number; error?: string } | null>(null);
const [title, setTitle] = useState("");
const [id, setId] = useState("");
const [idTouched, setIdTouched] = useState(false);
const [createErr, setCreateErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const nav = useNavigate();
useEffect(() => {
api<{ courses: CourseRow[]; canCreate: boolean; error?: string }>("GET", "/api/studio/courses").then(({ status, data }) => {
if (status !== 200) return setErr({ status, error: data?.error });
setRows(data.courses);
setCanCreate(data.canCreate);
});
}, []);
const create = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setCreateErr(null);
const { status, data } = await api("POST", "/api/studio/courses", { id, title });
setBusy(false);
if (status === 201) nav(`/studio/${encodeURIComponent(data.id)}`);
else setCreateErr(data?.error ?? `Could not create the course (${status})`);
};
const managed = rows?.filter((r) => r.managed) ?? [];
const outside = rows?.filter((r) => !r.managed) ?? [];
return (
<div className="studio studio-page">
<p className="chap">Studio</p>
<h1>Your courses</h1>
<p className="studio-lede">Edit on your own draft, save as you go, and publish when it is ready. Learners only see what you publish.</p>
{err ? <AccessMessage {...err} /> : null}
{canCreate ? (
<section className="studio-section" style={{ marginTop: 26 }} aria-labelledby="new-course">
<h2 id="new-course">New course</h2>
<form className="studio-form studio-form--inline" onSubmit={create}>
<label className="studio-field" style={{ flex: "2 1 260px" }}>
<span>Title</span>
<input type="text" required value={title} onChange={(e) => { setTitle(e.target.value); if (!idTouched) setId(slug(e.target.value)); }} />
</label>
<label className="studio-field" style={{ flex: "1 1 180px" }}>
<span>Course id</span>
<input type="text" className="code" required pattern="[a-z0-9][a-z0-9-]*" value={id} onChange={(e) => { setId(e.target.value); setIdTouched(true); }} />
</label>
<Pill type="submit" variant="primary" disabled={busy || !title.trim() || !id}>Create course</Pill>
</form>
{createErr ? <p className="studio-errors" role="alert">{createErr}</p> : null}
</section>
) : null}
{rows ? (
<section className="studio-section" aria-labelledby="studio-courses">
<h2 id="studio-courses">Studio courses</h2>
{managed.length === 0 ? <p className="faint">No studio courses yet.</p> : (
<ul className="studio-rows">
{managed.map((c) => (
<li className="studio-row" key={c.id}>
<CourseCover title={c.title} accent={c.accent ?? undefined} />
<div className="t">
<Link to={`/studio/${encodeURIComponent(c.id)}`}>{c.title}</Link>
<span className="s"><code>{c.id}</code>{c.pitch ? ` · ${c.pitch}` : ""}</span>
</div>
{c.merging ? <span className="studio-tag studio-tag--accent">Resolving conflicts</span> : null}
{c.ahead ? <span className="studio-tag studio-tag--accent">{c.ahead} unpublished</span> : null}
{c.behind ? <span className="studio-tag">{c.behind} new on published</span> : null}
{c.remote ? <span className="studio-tag">Remote</span> : null}
{c.publishedStatus === "error" ? <span className="studio-tag">Published version has errors</span> : null}
</li>
))}
</ul>
)}
</section>
) : null}
{outside.length ? (
<section className="studio-section" aria-labelledby="outside-courses">
<h2 id="outside-courses">Managed outside STCourses</h2>
<p className="faint" style={{ margin: 0 }}>These courses come from repositories the platform does not own. Edit them in their own repository.</p>
<ul className="studio-rows">
{outside.map((c) => (
<li className="studio-row" key={c.id}>
<CourseCover title={c.title} accent={c.accent ?? undefined} />
<div className="t">
<Link to={`/studio/${encodeURIComponent(c.id)}`}>{c.title}</Link>
<span className="s"><code>{c.id}</code> · {c.message}</span>
</div>
<span className="studio-tag">Read-only</span>
</li>
))}
</ul>
</section>
) : null}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Browser client for /api/studio. Types mirror the server responses (kept here so no server module enters the bundle).
export interface ApiError { status: number; body: any }
export async function api<T = any>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
const init: RequestInit = { method, headers: {} };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
(init.headers as Record<string, string>)["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
const r = await fetch(path, init);
const text = await r.text();
let data: any = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
return { status: r.status, data };
}
export const coursePath = (id: string, rest = "") => `/api/studio/courses/${encodeURIComponent(id)}${rest}`;
export const rawUrl = (id: string, rel: string) => coursePath(id, `/raw/${rel.split("/").map(encodeURIComponent).join("/")}`);
export interface ManifestLesson {
id: string; title: string; type: "text" | "video" | "exercise"; file?: string; anchor?: string; duration?: number; transcript?: string;
hints?: { file: string; match: string };
browser?: { language: "js" | "ts"; starter: string; tests: string; solution?: string };
localCommand?: string | Record<string, string>;
[k: string]: unknown;
}
export interface ManifestModule { id: string; title: string; lessons: ManifestLesson[]; [k: string]: unknown }
export interface ManifestDeck { id: string; file: string; title?: string; attachTo: string }
export interface ManifestAttachment { file: string; title?: string; module?: string }
export interface ManifestDoc {
schema: 1; id: string; title: string; pitch?: string; authors?: string[]; cover?: string; accent?: string; collections?: string[];
visibility?: "public" | "signed-in" | "restricted"; gating?: "none" | "sequential"; mentor?: string;
decks?: ManifestDeck[]; attachments?: ManifestAttachment[]; modules: ManifestModule[]; [k: string]: unknown;
}
export interface DraftStatus {
branch: string; publishedBranch: string; draftHead: string; publishedHead: string; ahead: number; behind: number;
merging: boolean; conflicts: string[]; remote: string | null; publishedDirty: string[];
}
export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string }
export interface CourseState {
readOnly: false; course: { id: string; title: string }; status: DraftStatus; manifestText: string; manifestHash: string | null;
manifestRaw: ManifestDoc | null; manifestErrors: string[]; diagnostics: Diagnostic[]; files: string[];
}
export interface ReadOnlyState { readOnly: true; message: string; course: { id: string; title: string; root: string }; diagnostics: Diagnostic[] }
export interface FileWrite { path: string; content?: string; baseHash?: string | null; delete?: boolean }
export interface FileConflict { path: string; current: string | null; currentHash: string | null }
export const slug = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
export const uniqueId = (base: string, taken: Set<string>) => {
let id = slug(base);
for (let n = 2; taken.has(id); n++) id = `${slug(base)}-${n}`;
return id;
};
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Pill } from "@stc/ui";
import { uniqueId } from "../api";
import type { Workspace } from "../workspace";
import { DiagnosticsList, Errors, Field, FilePicker, SaveBar, isImage, isMarkdown, useManifestValidation } from "./common";
const list = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean);
/** Manifest fields editor (structured), with live validation from @stc/course-format. */
export function CourseSettings({ ws }: { ws: Workspace }) {
const { doc, setDoc, state } = ws;
const errors = useManifestValidation(ws, { manifest: doc });
const [newModule, setNewModule] = useState("");
const set = <K extends keyof typeof doc>(k: K, v: (typeof doc)[K] | undefined) => setDoc((d) => {
if (v === undefined || v === "") delete d[k]; else d[k] = v;
});
return (
<div className="studio-page" style={{ maxWidth: 760, margin: 0 }}>
<p className="chap">Course settings</p>
<h1>{doc.title || "Untitled course"}</h1>
<p className="studio-lede">These fields live in <code>stcourse.yaml</code>. Changes go to your draft when you save.</p>
<div className="studio-form" style={{ marginTop: 22 }}>
<Field label="Title"><input type="text" value={doc.title} onChange={(e) => set("title", e.target.value)} /></Field>
<Field label="Pitch" hint="One or two sentences shown in the library.">
<textarea rows={2} value={doc.pitch ?? ""} onChange={(e) => set("pitch", e.target.value)} />
</Field>
<div className="grid2">
<Field label="Authors" hint="Comma-separated."><input type="text" value={(doc.authors ?? []).join(", ")} onChange={(e) => set("authors", list(e.target.value))} /></Field>
<Field label="Collections" hint="Comma-separated."><input type="text" value={(doc.collections ?? []).join(", ")} onChange={(e) => set("collections", list(e.target.value))} /></Field>
<Field label="Visibility">
<select value={doc.visibility ?? "restricted"} onChange={(e) => set("visibility", e.target.value as never)}>
<option value="restricted">Restricted: named users or groups</option>
<option value="signed-in">Signed-in: any user</option>
<option value="public">Public: anyone</option>
</select>
</Field>
<Field label="Module gating">
<select value={doc.gating ?? "none"} onChange={(e) => set("gating", e.target.value as never)}>
<option value="none">Off</option>
<option value="sequential">Sequential: a module unlocks when the previous one is done</option>
</select>
</Field>
<Field label="Accent colour" hint="Optional, as #rrggbb."><input type="text" className="code" value={doc.accent ?? ""} onChange={(e) => set("accent", e.target.value)} /></Field>
<Field label="Cover image"><FilePicker files={state.files} filter={isImage} allowEmpty value={doc.cover} onChange={(v) => set("cover", v)} /></Field>
<Field label="Mentor rules" hint="Instructions for the AI tutor."><FilePicker files={state.files} filter={isMarkdown} allowEmpty value={doc.mentor} onChange={(v) => set("mentor", v)} /></Field>
</div>
<div className="studio-section">
<h2>Modules</h2>
<ul className="studio-rows" style={{ marginTop: 0 }}>
{doc.modules.map((m, i) => (
<li className="studio-row" key={m.id}>
<span className="faint" style={{ width: 22 }}>{i + 1}</span>
<div className="t"><input type="text" aria-label={`Title of module ${m.id}`} value={m.title} onChange={(e) => setDoc((d) => { d.modules[i]!.title = e.target.value; })} /></div>
<span className="faint">{m.lessons.length} lessons</span>
<Pill variant="ghost" disabled={i === 0} onClick={() => setDoc((d) => { const [x] = d.modules.splice(i, 1); d.modules.splice(i - 1, 0, x!); })}>Move up</Pill>
<Pill variant="ghost" disabled={m.lessons.length > 0} title={m.lessons.length ? "Remove its lessons first" : undefined} onClick={() => setDoc((d) => { d.modules.splice(i, 1); })}>Remove</Pill>
</li>
))}
</ul>
<form className="studio-form studio-form--inline" style={{ marginTop: 12 }} onSubmit={(e) => {
e.preventDefault();
if (!newModule.trim()) return;
setDoc((d) => { d.modules.push({ id: uniqueId(newModule, new Set(d.modules.map((x) => x.id))), title: newModule.trim(), lessons: [] }); });
setNewModule("");
}}>
<Field label="New module"><input type="text" value={newModule} onChange={(e) => setNewModule(e.target.value)} /></Field>
<Pill type="submit" disabled={!newModule.trim()}>Add module</Pill>
</form>
</div>
<div className="studio-section">
<h2>Validation</h2>
{errors.length === 0 && !state.diagnostics.length ? <p className="faint" style={{ margin: 0 }}>The manifest is valid.</p> : null}
<Errors errors={errors} />
<DiagnosticsList diagnostics={state.diagnostics} />
</div>
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit course settings"} />
</div>
</div>
);
}
@@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { Pill } from "@stc/ui";
import { api, coursePath } from "../api";
import type { Workspace } from "../workspace";
import { Errors, Field, SaveBar, useManifestValidation } from "./common";
import { useFile } from "../workspace";
interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null }
/** Spec 9.1/12: conflicts are shown, and the author decides every file. Nothing is resolved automatically. */
export function ConflictView({ ws }: { ws: Workspace }) {
const [files, setFiles] = useState<ConflictFile[] | null>(null);
const [text, setText] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
api<{ files: ConflictFile[] }>("GET", coursePath(ws.courseId, "/conflicts")).then(({ data }) => {
setFiles(data.files);
setText(Object.fromEntries(data.files.map((f) => [f.path, f.working ?? ""])));
});
}, [ws.courseId, ws.state.status.draftHead, ws.state.status.conflicts.join("\0")]);
const resolve = async (path: string, content: string | null) => {
const { status, data } = await api("POST", coursePath(ws.courseId, "/resolve"), { files: [{ path, content }] });
setMsg(status === 200 ? (data.remaining.length ? null : "All conflicts resolved. Your draft now includes the published changes; publish when ready.") : data?.error);
await ws.reload();
};
return (
<div className="studio-page" style={{ margin: 0 }}>
<p className="chap">Conflicts</p>
<h1>Resolve conflicts</h1>
<p className="studio-lede">The published version and your draft changed the same lines. For each file, choose a version or edit the result, then mark it resolved. Your draft is not published until you publish it.</p>
{msg ? <div className="studio-note studio-note--ok" role="status">{msg}</div> : null}
{files?.map((f) => (
<section className="studio-conflict" key={f.path} aria-label={`Conflict in ${f.path}`}>
<h2><code>{f.path}</code></h2>
<div className="studio-sides">
<div><span className="faint">Your draft</span><pre>{f.ours ?? "(deleted in your draft)"}</pre></div>
<div><span className="faint">Published (changed outside your draft)</span><pre>{f.theirs ?? "(deleted on published)"}</pre></div>
</div>
<Field label="Result" hint="Starts with both versions between conflict markers. Remove the markers when you are done.">
<textarea className="code" rows={12} value={text[f.path] ?? ""} onChange={(e) => setText((t) => ({ ...t, [f.path]: e.target.value }))} />
</Field>
<div className="studio-actions" style={{ marginTop: 10 }}>
<Pill onClick={() => setText((t) => ({ ...t, [f.path]: f.ours ?? "" }))}>Use my draft</Pill>
<Pill onClick={() => setText((t) => ({ ...t, [f.path]: f.theirs ?? "" }))}>Use published</Pill>
<Pill variant="primary" disabled={/^(<{7}|>{7})/m.test(text[f.path] ?? "")} onClick={() => resolve(f.path, text[f.path] ?? "")}>Mark resolved</Pill>
{f.ours === null || f.theirs === null ? <Pill variant="ghost" onClick={() => resolve(f.path, null)}>Delete the file</Pill> : null}
</div>
</section>
))}
<div className="studio-section">
<Pill variant="ghost" onClick={async () => { await api("POST", coursePath(ws.courseId, "/abort")); await ws.reload(); }}>Cancel the update</Pill>
<span className="faint" style={{ marginLeft: 10 }}>Returns your draft to how it was before the update.</span>
</div>
</div>
);
}
export function SyncPanel({ ws }: { ws: Workspace }) {
const [url, setUrl] = useState(ws.state.status.remote ?? "");
const [msg, setMsg] = useState<{ kind: "ok" | "bad" | "warn"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const remote = ws.state.status.remote;
const saveRemote = async () => {
const { status, data } = await api("PUT", coursePath(ws.courseId, "/remote"), { url: url.trim() || null });
setMsg(status === 200 ? { kind: "ok", text: data.remote ? "Remote saved." : "Remote removed." } : { kind: "bad", text: data?.error });
await ws.reload();
};
const sync = async () => {
setBusy(true);
const { status, data } = await api("POST", coursePath(ws.courseId, "/sync"));
setBusy(false);
if (status === 200) setMsg({ kind: "ok", text: `Synced. ${data.pulled ? "Pulled changes from the remote. " : ""}${data.pushed ? "Pushed the published version." : ""}${!data.pulled && !data.pushed ? "Already up to date." : ""}` });
else if (data?.reason === "conflict") setMsg({ kind: "warn", text: `The remote changed the same lines as the published version (${data.conflicts.join(", ")}). Nothing was changed. Update your draft from the remote, resolve the conflicts, publish, then sync again.` });
else setMsg({ kind: "bad", text: data?.message ?? data?.error ?? `Sync failed (${status})` });
await ws.reload();
};
const fromRemote = async () => {
const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "remote" });
setMsg(status === 200 ? { kind: "ok", text: "Your draft now includes the remote's changes." } : status === 409 ? { kind: "warn", text: "Conflicts to resolve: see Conflicts." } : { kind: "bad", text: data?.message ?? data?.error });
await ws.reload();
};
return (
<div className="studio-page" style={{ maxWidth: 760, margin: 0 }}>
<p className="chap">Sync</p>
<h1>Remote repository</h1>
<p className="studio-lede">Nothing syncs on its own. Sync pulls the remote's published branch and pushes yours, only when you click it.</p>
<div className="studio-form" style={{ marginTop: 22 }}>
<form className="studio-form studio-form--inline" onSubmit={(e) => { e.preventDefault(); saveRemote(); }}>
<Field label="Remote URL" hint="Credentials come from the server (SSH keys or a token in the URL)."><input type="text" className="code" style={{ minWidth: 380 }} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="[email protected]:me/course.git" /></Field>
<Pill type="submit">Save remote</Pill>
</form>
{msg ? <div className={`studio-note studio-note--${msg.kind}`} role="status">{msg.text}</div> : null}
<div className="studio-actions">
<Pill variant="primary" disabled={!remote || busy} onClick={sync}>{busy ? "Syncing…" : "Sync now"}</Pill>
<Pill disabled={!remote} onClick={fromRemote}>Update my draft from the remote</Pill>
{!remote ? <span className="faint">Add a remote to enable Sync.</span> : null}
</div>
</div>
</div>
);
}
/** The whole manifest as YAML, for anything the structured editors do not cover. */
export function RawManifest({ ws }: { ws: Workspace }) {
const f = useFile(ws.courseId, "stcourse.yaml", ws.version);
const errors = useManifestValidation(ws, { manifestText: f.content });
return (
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
<p className="chap">Manifest</p>
<h1><code style={{ fontSize: 24 }}>stcourse.yaml</code></h1>
<p className="studio-lede">The source of truth for the course structure. Unsaved changes in the other editors are not shown here.</p>
<div className="studio-form" style={{ maxWidth: "none", marginTop: 18 }}>
<textarea className="code" rows={28} spellCheck={false} value={f.content} onChange={(e) => f.setContent(e.target.value)} aria-label="stcourse.yaml" />
{errors.length ? <Errors errors={errors} /> : <p className="faint" style={{ margin: 0 }}>Valid manifest.</p>}
<SaveBar ws={ws} dirty={f.dirty} withManifest={false} collect={() => [f.write()!]} message={() => "Edit stcourse.yaml"} />
</div>
</div>
);
}
@@ -0,0 +1,168 @@
import { Pill } from "@stc/ui";
import type { FileWrite, ManifestLesson } from "../api";
import { LessonPreview } from "../preview";
import { useFile, type OpenFile, type Workspace } from "../workspace";
import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isMarkdown, isVideo, useManifestValidation } from "./common";
const STARTER = (lang: string) => `// Starter code for the learner.\nexport function solve(input${lang === "ts" ? ": unknown" : ""}) {\n // TODO\n}\n`;
const TESTS = `// export const tests: { name, run(mod, assert) }[]\nexport const tests = [\n {\n name: "solves the example",\n run(mod, assert) {\n assert.equal(mod.solve(1), 1);\n },\n },\n];\n`;
const HINT = (match: string, label: string) => `<details>\n<summary>${match} — ${label}</summary>\n\nWrite the hint here.\n\n</details>\n`;
function CodeFile({ label, file, rows = 10 }: { label: string; file: OpenFile; rows?: number }) {
if (!file.path) return null;
return (
<Field label={`${label} · ${file.path}`} hint={file.loaded && !file.exists ? "New file, created when you save." : undefined}>
<textarea className="code" rows={rows} spellCheck={false} value={file.content} onChange={(e) => file.setContent(e.target.value)} />
</Field>
);
}
export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId: string; onGone: () => void }) {
const { doc, setDoc, state } = ws;
let mi = -1, li = -1;
doc.modules.forEach((m, i) => m.lessons.forEach((l, j) => { if (l.id === lessonId) { mi = i; li = j; } }));
const lesson: ManifestLesson | undefined = mi >= 0 ? doc.modules[mi]!.lessons[li] : undefined;
// the Markdown body: the lesson file for text/exercise lessons, the transcript for videos
const mdPath = lesson ? (lesson.type === "video" ? lesson.transcript : lesson.file && isMarkdown(lesson.file) ? lesson.file : undefined) : undefined;
const lang = lesson?.browser?.language ?? "js";
const body = useFile(ws.courseId, mdPath, ws.version, lesson ? `# ${lesson.title}\n\n` : "");
const starter = useFile(ws.courseId, lesson?.browser?.starter, ws.version, STARTER(lang));
const tests = useFile(ws.courseId, lesson?.browser?.tests, ws.version, TESTS);
const solution = useFile(ws.courseId, lesson?.browser?.solution, ws.version, STARTER(lang).replace("// TODO", "return input;"));
const hints = useFile(ws.courseId, lesson?.hints?.file !== mdPath ? lesson?.hints?.file : undefined, ws.version);
const errors = useManifestValidation(ws, { manifest: doc });
if (!lesson) return <div className="studio-note">This lesson is no longer in the manifest. <Pill variant="ghost" onClick={onGone}>Back to course settings</Pill></div>;
const upd = (f: (l: ManifestLesson) => void) => setDoc((d) => f(d.modules[mi]!.lessons[li]!));
const setOpt = <K extends keyof ManifestLesson>(k: K, v: ManifestLesson[K] | undefined) => upd((l) => { if (v === undefined || v === "") delete l[k]; else l[k] = v; });
const files = [body, starter, tests, solution, hints];
const dirty = ws.docDirty || files.some((f) => f.dirty);
const collect = (): FileWrite[] => files.filter((f) => f.path && (f.dirty || (!f.exists && f.loaded))).map((f) => f.write()!).filter((w, i, a) => a.findIndex((x) => x.path === w.path) === i);
const lessonErrors = errors.filter((e) => e.startsWith(`modules.${mi}.lessons.${li}.`));
const exDir = `exercises/${lesson.id}`;
return (
<div>
<p className="chap">{doc.modules[mi]!.title} · {lesson.type === "text" ? "Text lesson" : lesson.type === "video" ? "Video lesson" : "Exercise"}</p>
<div className="studio-form" style={{ maxWidth: "none" }}>
<div className="grid3">
<Field label="Title"><input type="text" value={lesson.title} onChange={(e) => upd((l) => { l.title = e.target.value; })} /></Field>
<Field label="Type">
<select value={lesson.type} onChange={(e) => upd((l) => { l.type = e.target.value as ManifestLesson["type"]; })}>
<option value="text">Text</option><option value="video">Video</option><option value="exercise">Exercise</option>
</select>
</Field>
<Field label="Duration (minutes)">
<input type="number" min={0} step={1} value={lesson.duration ? Math.round(lesson.duration / 60) : ""} onChange={(e) => setOpt("duration", e.target.value ? Number(e.target.value) * 60 : undefined)} />
</Field>
{lesson.type === "video" ? (
<>
<Field label="Video file"><FilePicker files={state.files} filter={isVideo} value={lesson.file} onChange={(v) => setOpt("file", v)} /></Field>
<Field label="Transcript (Markdown)"><FilePicker files={state.files} filter={isMarkdown} allowEmpty value={lesson.transcript} onChange={(v) => setOpt("transcript", v)} /></Field>
<div className="studio-field"><span>Upload</span><div className="studio-actions"><UploadButton ws={ws} dir="media" accept="video/*" label="Upload video" onUploaded={(p) => { setOpt("file", p); }} /></div></div>
</>
) : (
<>
<Field label="Lesson file"><FilePicker files={state.files} filter={isMarkdown} allowEmpty={lesson.type === "exercise"} value={lesson.file} onChange={(v) => setOpt("file", v)} /></Field>
<Field label="Heading anchor" hint="Optional: show only this section of the file."><input type="text" className="code" value={lesson.anchor ?? ""} onChange={(e) => setOpt("anchor", e.target.value)} /></Field>
</>
)}
</div>
{lesson.type === "video" && !lesson.transcript ? (
<div className="studio-actions"><Pill onClick={() => setOpt("transcript", `lessons/${lesson.id}.md`)}>Add a transcript file</Pill></div>
) : null}
<Errorsish errors={lessonErrors} ws={ws} lessonId={lesson.id} />
{mdPath ? (
<div className="studio-split">
<Field label={`Markdown · ${mdPath}`} hint={body.loaded && !body.exists ? "New file, created when you save." : undefined}>
<textarea className="code" spellCheck value={body.content} onChange={(e) => body.setContent(e.target.value)} aria-describedby="preview-label" />
</Field>
<div className="studio-preview-wrap" aria-live="off">
<p className="faint" id="preview-label" style={{ margin: "0 0 10px" }}>Preview</p>
<LessonPreview courseId={ws.courseId} markdown={body.content} lesson={lesson} resolveUrl={(rel) => ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
</div>
</div>
) : null}
{lesson.type === "exercise" ? (
<div className="studio-section">
<h2>Exercise</h2>
<div className="grid2">
<Field label="Local test command" hint="Shown to learners who run the exercise on their machine.">
<input type="text" className="code" value={typeof lesson.localCommand === "string" ? lesson.localCommand : ""} onChange={(e) => setOpt("localCommand", e.target.value)} />
</Field>
<Field label="Browser runner">
<select value={lesson.browser?.language ?? ""} onChange={(e) => {
const lang = e.target.value as "js" | "ts" | "";
upd((l) => {
if (!lang) { delete l.browser; return; }
l.browser = l.browser ? { ...l.browser, language: lang } : { language: lang, starter: `${exDir}/starter.${lang}`, tests: `${exDir}/tests.${lang}`, solution: `${exDir}/solution.${lang}` };
});
}}>
<option value="">None (runs locally)</option><option value="js">JavaScript</option><option value="ts">TypeScript</option>
</select>
</Field>
</div>
{lesson.browser ? (
<>
<CodeFile label="Starter" file={starter} />
<CodeFile label="Tests" file={tests} />
<CodeFile label="Solution (authors only)" file={solution} />
</>
) : null}
<h3>Hints</h3>
{lesson.hints ? (
<>
<div className="grid2">
<Field label="Hints file"><FilePicker files={state.files} filter={isMarkdown} value={lesson.hints.file} onChange={(v) => upd((l) => { if (v) l.hints = { ...l.hints!, file: v }; })} /></Field>
<Field label="Summary label" hint="Hint blocks whose summary starts with this label belong to this exercise.">
<input type="text" value={lesson.hints.match} onChange={(e) => upd((l) => { l.hints = { ...l.hints!, match: e.target.value }; })} />
</Field>
</div>
{hints.path ? <CodeFile label="Hint tiers" file={hints} rows={12} /> : <p className="faint">The hints live in the lesson file above.</p>}
<div className="studio-actions">
{(["nudge", "stronger hint", "near-solution"] as const).map((tier) => (
<Pill key={tier} variant="ghost" onClick={() => {
const target = hints.path ? hints : body;
target.setContent((target.content.trimEnd() ? target.content.trimEnd() + "\n\n" : "") + HINT(lesson.hints!.match, tier));
}}>Add {tier}</Pill>
))}
</div>
</>
) : (
<Pill onClick={() => {
const match = lesson.title;
upd((l) => { l.hints = { file: `${exDir}/hints.md`, match }; });
}}>Add hint tiers</Pill>
)}
</div>
) : null}
<SaveBar ws={ws} dirty={dirty} collect={collect} message={() => `Edit lesson ${lesson.id}`} extra={
<>
<Pill variant="ghost" disabled={li === 0} onClick={() => setDoc((d) => { const ls = d.modules[mi]!.lessons; const [x] = ls.splice(li, 1); ls.splice(li - 1, 0, x!); })}>Move up</Pill>
<Pill variant="ghost" onClick={() => {
if (!window.confirm(`Remove "${lesson.title}" from the course? Its files stay in the repository.`)) return;
setDoc((d) => { d.modules[mi]!.lessons.splice(li, 1); });
onGone();
}}>Remove lesson</Pill>
</>
} />
<p className="faint" style={{ margin: 0 }}>Saved changes stay on your draft. Learners see them after you publish.</p>
</div>
</div>
);
}
function Errorsish({ errors, ws, lessonId }: { errors: string[]; ws: Workspace; lessonId: string }) {
return (
<>
{errors.length ? <ul className="studio-errors">{errors.map((e) => <li key={e}>{e}</li>)}</ul> : null}
<DiagnosticsList diagnostics={ws.state.diagnostics} filter={(d) => d.lessonId === lessonId} />
</>
);
}
@@ -0,0 +1,87 @@
import { Pill } from "@stc/ui";
import { slug, uniqueId, type ManifestDoc } from "../api";
import type { Workspace } from "../workspace";
import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isPdf } from "./common";
const targets = (doc: ManifestDoc) => [
...doc.modules.map((m) => ({ id: m.id, label: `Module: ${m.title}` })),
...doc.modules.flatMap((m) => m.lessons.map((l) => ({ id: l.id, label: `${m.title} · ${l.title}` }))),
];
/** Deck links: a PDF deck attached to a lesson (for example its video) or a module. */
export function DecksEditor({ ws }: { ws: Workspace }) {
const { doc, setDoc, state } = ws;
const decks = doc.decks ?? [];
const opts = targets(doc);
const add = (file: string) => setDoc((d) => {
const ids = new Set((d.decks ?? []).map((x) => x.id));
const name = file.split("/").pop()!.replace(/\.pdf$/i, "");
(d.decks ??= []).push({ id: uniqueId(slug(name), ids), file, title: name, attachTo: opts[0]?.id ?? "" });
});
return (
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
<p className="chap">Deck links</p>
<h1>Slide decks</h1>
<p className="studio-lede">A deck is a PDF. Link it to the lesson or module it belongs to, and learners see it next to that content.</p>
<ul className="studio-rows">
{decks.length === 0 ? <li className="studio-row faint">No decks yet.</li> : null}
{decks.map((dk, i) => (
<li className="studio-row" key={i} style={{ alignItems: "end" }}>
<div className="t grid3" style={{ gridTemplateColumns: "1.2fr 1fr 1.2fr" }}>
<Field label="Title"><input type="text" value={dk.title ?? ""} onChange={(e) => setDoc((d) => { d.decks![i]!.title = e.target.value || undefined; })} /></Field>
<Field label="PDF"><FilePicker files={state.files} filter={isPdf} value={dk.file} onChange={(v) => v && setDoc((d) => { d.decks![i]!.file = v; })} /></Field>
<Field label="Attached to">
<select value={dk.attachTo} onChange={(e) => setDoc((d) => { d.decks![i]!.attachTo = e.target.value; })}>
{opts.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
</div>
<Pill variant="ghost" onClick={() => setDoc((d) => { d.decks!.splice(i, 1); if (!d.decks!.length) delete d.decks; })}>Remove</Pill>
</li>
))}
</ul>
<div className="studio-actions" style={{ margin: "14px 0 20px" }}>
<UploadButton ws={ws} dir="media" accept="application/pdf,.pdf" label="Upload a PDF deck" onUploaded={add} />
{state.files.some(isPdf) ? <Pill variant="ghost" onClick={() => add(state.files.find(isPdf)!)}>Link an existing PDF</Pill> : null}
</div>
<DiagnosticsList diagnostics={state.diagnostics} filter={(d) => /deck/.test(d.message)} />
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit deck links"} />
</div>
);
}
/** Course attachments (PDFs, images, code archives), optionally scoped to a module. */
export function AttachmentsEditor({ ws }: { ws: Workspace }) {
const { doc, setDoc, state } = ws;
const list = doc.attachments ?? [];
return (
<div className="studio-page" style={{ maxWidth: 900, margin: 0 }}>
<p className="chap">Attachments</p>
<h1>Attachments</h1>
<p className="studio-lede">Files learners can download. Uploads are committed to your draft at once; the list below is saved with the manifest.</p>
<ul className="studio-rows">
{list.length === 0 ? <li className="studio-row faint">No attachments yet.</li> : null}
{list.map((a, i) => (
<li className="studio-row" key={i} style={{ alignItems: "end" }}>
<div className="t grid3" style={{ gridTemplateColumns: "1.3fr 1fr 1fr" }}>
<Field label="File"><a href={ws.resolveUrl(a.file)} className="code" style={{ fontFamily: "var(--mono)", fontSize: 12.5, lineHeight: "34px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.file}</a></Field>
<Field label="Title"><input type="text" value={a.title ?? ""} onChange={(e) => setDoc((d) => { d.attachments![i]!.title = e.target.value || undefined; })} /></Field>
<Field label="Module">
<select value={a.module ?? ""} onChange={(e) => setDoc((d) => { if (e.target.value) d.attachments![i]!.module = e.target.value; else delete d.attachments![i]!.module; })}>
<option value="">Whole course</option>
{doc.modules.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
</select>
</Field>
</div>
<Pill variant="ghost" onClick={() => setDoc((d) => { d.attachments!.splice(i, 1); if (!d.attachments!.length) delete d.attachments; })}>Remove</Pill>
</li>
))}
</ul>
<div className="studio-actions" style={{ margin: "14px 0 20px" }}>
<UploadButton ws={ws} dir="attachments" label="Upload a file" onUploaded={(p) => setDoc((d) => { (d.attachments ??= []).push({ file: p, title: p.split("/").pop() }); })} />
</div>
<DiagnosticsList diagnostics={state.diagnostics} filter={(d) => /attachment/.test(d.message)} />
<SaveBar ws={ws} dirty={ws.docDirty} collect={() => []} message={() => "Edit attachments"} />
</div>
);
}
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Pill } from "@stc/ui";
import { api, coursePath, type Diagnostic, type FileConflict, type FileWrite } from "../api";
import type { SaveOutcome, Workspace } from "../workspace";
export function Field({ label, hint, children, wide }: { label: string; hint?: ReactNode; children: ReactNode; wide?: boolean }) {
return (
<label className="studio-field" style={wide ? { gridColumn: "1 / -1" } : undefined}>
<span>{label}</span>
{children}
{hint ? <small>{hint}</small> : null}
</label>
);
}
export function Errors({ errors }: { errors: string[] }) {
if (!errors.length) return null;
return <ul className="studio-errors" aria-live="polite">{errors.map((e, i) => <li key={i}>{e}</li>)}</ul>;
}
export function DiagnosticsList({ diagnostics, filter }: { diagnostics: Diagnostic[]; filter?: (d: Diagnostic) => boolean }) {
const list = filter ? diagnostics.filter(filter) : diagnostics;
if (!list.length) return null;
return <Errors errors={list.map((d) => `${d.level === "warning" ? "warning: " : ""}${d.lessonId ? `${d.lessonId}: ` : ""}${d.message}`)} />;
}
/** Live manifest validation through @stc/course-format on the server, debounced. */
export function useManifestValidation(ws: Workspace, input: { manifest?: unknown; manifestText?: string }) {
const [errors, setErrors] = useState<string[]>(ws.state.manifestErrors);
const key = JSON.stringify(input);
useEffect(() => {
const t = setTimeout(async () => {
const { status, data } = await api("POST", coursePath(ws.courseId, "/validate"), input);
if (status === 200) setErrors(data.errors);
}, 350);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ws.courseId]);
return errors;
}
/**
* Save button with the "changed outside the studio" flow: on a 409 it lists the files and lets the author
* either reload the outside version or explicitly keep theirs. Nothing is overwritten without that choice.
*/
export function SaveBar({ ws, dirty, collect, message, label = "Save draft", onSaved, extra, withManifest }: {
ws: Workspace; dirty: boolean; withManifest?: boolean; collect: () => FileWrite[]; message: () => string; label?: string; onSaved?: () => void; extra?: ReactNode;
}) {
const [busy, setBusy] = useState(false);
const [conflicts, setConflicts] = useState<FileConflict[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const pending = useRef<FileWrite[]>([]);
const run = async (files: FileWrite[]) => {
setBusy(true); setError(null); setSaved(false);
const r: SaveOutcome = await ws.save({ files, message: message(), withManifest });
setBusy(false);
if (r.ok) { setConflicts(null); setSaved(true); onSaved?.(); return; }
if (r.conflicts) { pending.current = files; setConflicts(r.conflicts); }
else setError(r.error ?? "Save failed");
};
return (
<div>
<div className="studio-actions">
<Pill variant="primary" disabled={busy || !dirty} onClick={() => run(collect())}>{busy ? "Saving…" : label}</Pill>
{extra}
{saved && !dirty ? <span className="faint" role="status">Saved to your draft</span> : null}
{dirty ? <span className="faint">Unsaved changes</span> : null}
</div>
{error ? <div className="studio-note studio-note--bad" role="alert">{error}</div> : null}
{conflicts ? (
<div className="studio-note studio-note--warn" role="alert">
<strong>Changed outside the studio since you opened it:</strong> {conflicts.map((c) => c.path).join(", ")}.
<div className="studio-sides">
{conflicts.map((c) => (
<div key={c.path}><span className="faint">{c.path}, current version on disk</span><pre>{c.current ?? "(deleted)"}</pre></div>
))}
</div>
<div className="studio-actions">
<Pill onClick={() => { setConflicts(null); ws.reload().then(() => onSaved?.()); }}>Discard mine, load theirs</Pill>
<Pill onClick={() => run(pending.current.map((f) => {
const c = conflicts.find((x) => x.path === f.path);
return c ? { ...f, baseHash: c.currentHash } : f;
}))}>Keep mine and save over theirs</Pill>
</div>
</div>
) : null}
</div>
);
}
export function FilePicker({ files, value, onChange, filter, allowEmpty, id }: {
files: string[]; value: string | undefined; onChange: (v: string | undefined) => void; filter?: (f: string) => boolean; allowEmpty?: boolean; id?: string;
}) {
const list = filter ? files.filter(filter) : files;
const opts = value && !list.includes(value) ? [value, ...list] : list;
return (
<select id={id} value={value ?? ""} onChange={(e) => onChange(e.target.value || undefined)}>
{allowEmpty || !value ? <option value="">None</option> : null}
{opts.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
);
}
export function UploadButton({ ws, dir, accept, label, onUploaded }: { ws: Workspace; dir: string; accept?: string; label: string; onUploaded: (path: string) => void }) {
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
return (
<>
<input ref={ref} type="file" accept={accept} className="sr" aria-label={label} tabIndex={-1} onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (!f) return;
setBusy(true); setErr(null);
const r = await ws.upload(f, dir);
setBusy(false);
if (r.ok) onUploaded(r.path); else setErr(r.error);
}} />
<Pill onClick={() => ref.current?.click()} disabled={busy}>{busy ? "Uploading…" : label}</Pill>
{err ? <span className="studio-errors" role="alert">{err}</span> : null}
</>
);
}
export const isMarkdown = (f: string) => /\.(md|markdown)$/i.test(f);
export const isPdf = (f: string) => /\.pdf$/i.test(f);
export const isVideo = (f: string) => /\.(mp4|webm|mov|m4v)$/i.test(f);
export const isImage = (f: string) => /\.(png|jpe?g|svg|webp|gif)$/i.test(f);
+129
View File
@@ -0,0 +1,129 @@
/**
* A small, safe Markdown renderer for the studio's live preview (v1 adapter; Phase 2 swaps in the learner's LessonView).
* Everything is HTML-escaped; the only raw HTML let through is the bare `<details>`/`<summary>` used for hint tiers.
*/
export interface RenderOptions {
/** Maps a relative image/link URL in the lesson to something the browser can load (the draft raw endpoint). */
resolveUrl?: (url: string) => string;
}
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const unesc = (s: string) => s.replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
/** Same rule as @stc/course-format's slugify (GitHub style), duplicated to keep node-only modules out of the browser bundle. */
export function slugify(heading: string): string {
return heading
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[`*~]/g, "")
.replace(/<[^>]+>/g, "")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "")
.replace(/\s/g, "-");
}
const SAFE_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/|[^:]*$)/i;
const URL_PART = String.raw`((?:[^()]|\([^()]*\))*)`;
function inline(text: string, o: RenderOptions): string {
const codes: string[] = [];
let s = esc(text).replace(/`([^`]+)`/g, (_, c: string) => `\u0000${codes.push(`<code>${c}</code>`) - 1}\u0000`);
const url = (raw: string) => {
const u = unesc(raw.trim());
if (!SAFE_SCHEME.test(u)) return null;
const isRelative = !/^([a-z]+:|\/|#)/i.test(u);
return esc(isRelative && o.resolveUrl ? o.resolveUrl(u) : u);
};
s = s.replace(new RegExp(String.raw`!\[([^\]]*)\]\(` + URL_PART + String.raw`\)`, "g"), (_, alt: string, u: string) => {
const href = url(u);
return href ? `<img src="${href}" alt="${alt}">` : alt;
});
s = s.replace(new RegExp(String.raw`\[([^\]]+)\]\(` + URL_PART + String.raw`\)`, "g"), (_, label: string, u: string) => {
const href = url(u);
return href ? `<a href="${href}">${label}</a>` : label;
});
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/__(.+?)__/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*\w])\*(?!\s)(.+?)\*(?!\w)/g, "$1<em>$2</em>").replace(/(^|[^_\w])_(?!\s)(.+?)_(?!\w)/g, "$1<em>$2</em>");
return s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => codes[Number(i)]!);
}
export function renderMarkdown(md: string, o: RenderOptions = {}): string {
let lines = md.replace(/\r\n?/g, "\n").split("\n");
if (lines[0] === "---") {
const end = lines.indexOf("---", 1);
if (end > 0) lines = lines.slice(end + 1);
}
const out: string[] = [];
const slugs = new Map<string, number>();
let i = 0;
const isBlockStart = (l: string) =>
/^ {0,3}(#{1,6}\s|```|~~~|>|[-*+]\s|\d+[.)]\s|(-{3,}|\*{3,}|_{3,})\s*$)/.test(l) || /^\s*<\/?(details|summary)>/.test(l);
while (i < lines.length) {
const line = lines[i]!;
if (!line.trim()) { i++; continue; }
const fence = /^ {0,3}(`{3,}|~{3,})\s*([\w+-]*)/.exec(line);
if (fence) {
const body: string[] = [];
i++;
while (i < lines.length && !lines[i]!.trimStart().startsWith(fence[1]!)) body.push(lines[i++]!);
i++;
const lang = fence[2] ? ` data-lang="${esc(fence[2])}"` : "";
out.push(`<pre class="code"><code${lang}>${esc(body.join("\n"))}</code></pre>`);
continue;
}
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
if (h) {
const base = slugify(h[2]!);
const n = slugs.get(base) ?? 0;
slugs.set(base, n + 1);
const lvl = h[1]!.length;
out.push(`<h${lvl} id="${esc(n ? `${base}-${n}` : base)}">${inline(h[2]!, o)}</h${lvl}>`);
i++;
continue;
}
if (/^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
// hint blocks: <details>, <summary>label</summary>, </details>
const det = /^\s*<(\/?)details>\s*$/.exec(line);
if (det) { out.push(det[1] ? "</details>" : "<details>"); i++; continue; }
const sum = /^\s*(<details>)?\s*<summary>(.*?)<\/summary>\s*$/.exec(line);
if (sum) {
out.push(`${sum[1] ? "<details>" : ""}<summary>${inline(sum[2]!, o)}</summary>`);
i++;
continue;
}
if (/^ {0,3}>/.test(line)) {
const body: string[] = [];
while (i < lines.length && /^ {0,3}>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^ {0,3}> ?/, ""));
out.push(`<blockquote>${renderMarkdown(body.join("\n"), o)}</blockquote>`);
continue;
}
const list = /^ {0,3}([-*+]|\d+[.)])\s+/.exec(line);
if (list) {
const ordered = /\d/.test(list[1]!);
const re = ordered ? /^ {0,3}\d+[.)]\s+(.*)$/ : /^ {0,3}[-*+]\s+(.*)$/;
const items: string[] = [];
while (i < lines.length) {
const m = re.exec(lines[i]!);
if (m) { items.push(m[1]!); i++; continue; }
if (lines[i]!.trim() && /^\s{2,}/.test(lines[i]!) && items.length) { items[items.length - 1] += "\n" + lines[i]!.trim(); i++; continue; }
break;
}
const tag = ordered ? "ol" : "ul";
out.push(`<${tag}>${items.map((it) => `<li>${inline(it, o)}</li>`).join("")}</${tag}>`);
continue;
}
const para: string[] = [];
while (i < lines.length && lines[i]!.trim() && (para.length === 0 || !isBlockStart(lines[i]!))) para.push(lines[i++]!);
out.push(`<p>${inline(para.join("\n"), o)}</p>`);
}
return out.join("\n");
}
@@ -0,0 +1,28 @@
import type { ComponentType } from "react";
import { renderMarkdown } from "./markdown";
/**
* Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner
* package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md).
*/
export interface LessonPreviewProps {
courseId: string;
markdown: string;
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
/** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */
resolveUrl: (rel: string) => string;
}
export type LessonPreviewComponent = ComponentType<LessonPreviewProps>;
export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewProps) {
return <div className="studio-preview prose reading" dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown, { resolveUrl }) }} />;
}
let current: LessonPreviewComponent = SimpleMarkdownPreview;
export function setLessonPreview(c: LessonPreviewComponent): void {
current = c;
}
export function LessonPreview(props: LessonPreviewProps) {
const C = current;
return <C {...props} />;
}
+98
View File
@@ -0,0 +1,98 @@
/* Studio: the denser "Work" look of the mockup. Tokens only; no new colours. */
.studio { font-family: var(--sans); font-size: 14px; line-height: 1.55; color: var(--text); }
.studio-page { max-width: 1180px; margin: 0 auto; padding-bottom: 64px; }
.studio h1 { font: 600 28px/1.12 var(--sans); color: var(--head); margin: 0; letter-spacing: -.012em; }
.studio h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 10px; }
.studio h3 { font: 600 14px/1.3 var(--sans); color: var(--head); margin: 18px 0 8px; }
.studio .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
.studio .muted { color: var(--muted); }
.studio .faint { color: var(--faint); font-size: 12.5px; }
.studio code { font-size: 12.5px; }
.studio-lede { color: var(--muted); margin: 8px 0 0; max-width: 760px; }
/* rows separated by thin rules */
.studio-rows { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
.studio-row { display: flex; align-items: center; gap: 14px; padding: 12px 2px; border-bottom: 1px solid var(--line); }
.studio-row .t { flex: 1; min-width: 0; }
.studio-row .t a, .studio-row .t strong { color: var(--head); font-weight: 500; text-decoration: none; font-size: 14.5px; }
.studio-row .t a:hover { text-decoration: underline; }
.studio-row .s { display: block; color: var(--faint); font-size: 12.5px; margin-top: 2px; }
.studio-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border: 1px solid var(--line2); border-radius: 11px; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.studio-tag--accent { border-color: var(--accent-dim); color: var(--accent); }
/* forms */
.studio-form { display: grid; gap: 14px; max-width: 760px; }
.studio-form--inline { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; max-width: none; }
.studio-field { display: grid; gap: 5px; min-width: 0; align-content: start; }
.studio-field > span { font-size: 12px; color: var(--faint); }
.studio-field small { font-size: 12px; color: var(--faint); }
.studio input[type="text"], .studio input[type="url"], .studio input[type="number"], .studio select, .studio textarea {
width: 100%; min-width: 0; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--side); color: var(--text); font: inherit; font-size: 13.5px;
}
.studio select { padding-right: 8px; }
.studio textarea { height: auto; padding: 10px 12px; line-height: 1.55; resize: vertical; }
.studio textarea.code, .studio input.code { font-family: var(--mono); font-size: 13px; tab-size: 2; }
.studio input:focus, .studio select:focus, .studio textarea:focus { outline: none; border-color: var(--faint); }
.studio .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.studio .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.studio-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* messages */
.studio-note { border-left: 2px solid var(--line2); padding: 8px 0 8px 14px; color: var(--muted); margin: 14px 0; }
.studio-note strong { color: var(--text); font-weight: 500; }
.studio-note--warn { border-left-color: var(--accent); }
.studio-note--bad { border-left-color: var(--bad); }
.studio-note--ok { border-left-color: var(--ok); }
.studio-errors { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12.5px; color: var(--bad); }
.studio-errors li { padding: 2px 0; font-family: var(--mono); }
/* course workspace */
.studio-side-head { padding: 18px 16px 14px; border-bottom: 1px solid var(--line); }
.studio-side-head h2 { font-size: 14.5px; margin: 0; }
.studio-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
.studio-nav { list-style: none; margin: 0; padding: 6px 0; border-bottom: 1px solid var(--line); }
.studio-nav button, .studio-nav a { display: grid; grid-template-columns: 1fr auto; gap: 6px; width: 100%; align-items: center; padding: 6px 16px; border: 0; background: none; text-align: left; font-size: 13px; color: var(--muted); text-decoration: none; }
.studio-nav button:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
.studio-nav button[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
.studio-nav .mod { padding: 11px 16px 4px; font-size: 13.5px; color: var(--text); font-weight: 500; }
.studio-nav .k { font-size: 11.5px; color: var(--faint); }
.studio-nav .add { color: var(--faint); font-size: 12.5px; }
.studio-ws { display: flex; align-items: flex-start; margin: -44px -40px 0; }
.studio-aside { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
.studio-aside .src a { color: var(--muted); }
.studio-content { flex: 1; min-width: 0; padding: 0 40px 64px; }
.studio-bar { position: sticky; top: var(--top); z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 -40px 26px; padding: 10px 40px; background: var(--ground); border-bottom: 1px solid var(--line); }
.studio-bar .where { flex: 1; min-width: 200px; font-size: 12.5px; color: var(--muted); }
.studio-bar .where b { color: var(--text); font-weight: 500; }
.studio-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
.studio-split textarea { min-height: 520px; }
.studio-preview-wrap { border-left: 1px solid var(--line); padding-left: 24px; min-width: 0; }
.studio-preview { --pf: var(--serif); font-size: 17px; }
.studio-preview :where(h1, h2, h3) { font-family: var(--serif); color: var(--head); }
.studio-preview h1 { font-size: 26px; line-height: 1.2; margin: 0 0 .6em; }
.studio-preview pre.code { font-size: 13px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; overflow-x: auto; color: var(--text); }
.studio-preview :not(pre) > code { font-size: .8em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
.studio-preview blockquote { border-left: 2px solid var(--line2); margin: 0 0 1em; padding-left: 16px; color: var(--muted); }
.studio-preview details { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.2em; padding: 0 2px; }
.studio-preview summary { padding: 10px 0; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
.studio-preview img { max-width: 100%; }
.studio-preview hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
.studio-section { padding: 18px 0; border-top: 1px solid var(--line); }
.studio-section:first-of-type { border-top: 0; padding-top: 0; }
/* conflicts */
.studio-conflict { border-top: 1px solid var(--line); padding: 16px 0; }
.studio-sides { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 10px 0; }
.studio-sides pre { margin: 0; max-height: 280px; overflow: auto; font-size: 12.5px; line-height: 1.55; background: var(--side); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; white-space: pre-wrap; }
@media (max-width: 900px) {
.studio-split, .studio-sides, .studio .grid2, .studio .grid3 { grid-template-columns: 1fr; }
.studio-preview-wrap { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 18px; }
.studio-ws { flex-direction: column; margin: -44px -40px 0; }
.studio-aside { position: static; width: 100%; height: auto; max-height: 50vh; border-right: 0; border-bottom: 1px solid var(--line); }
.studio-content { padding: 0 16px 48px; width: 100%; }
.studio-bar { margin: 0 -16px 20px; padding: 10px 16px; }
}
+110
View File
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from "react";
import { api, coursePath, rawUrl, type CourseState, type FileConflict, type FileWrite, type ManifestDoc } from "./api";
export type SaveOutcome = { ok: true } | { ok: false; conflicts?: FileConflict[]; error?: string };
/** What every editor gets: the loaded draft, a local manifest copy shared across editors, and save helpers. */
export interface Workspace {
courseId: string;
state: CourseState;
doc: ManifestDoc;
docDirty: boolean;
setDoc(update: (d: ManifestDoc) => void): void;
/** One commit: optional files plus the local manifest when it has changes (or when `withManifest`). */
save(opts: { files?: FileWrite[]; message: string; withManifest?: boolean; manifest?: ManifestDoc }): Promise<SaveOutcome>;
upload(file: File, dir: string): Promise<{ ok: true; path: string } | { ok: false; error: string }>;
reload(): Promise<void>;
/** Bumped after every save so open file editors refetch their base hash. */
version: number;
resolveUrl(rel: string): string;
}
export function clone<T>(x: T): T {
return JSON.parse(JSON.stringify(x)) as T;
}
/** A course file opened for editing, with the hash it was loaded at (for conflict detection on save). */
export function useFile(courseId: string, path: string | undefined, version: number, initialIfMissing = "") {
const [s, setS] = useState({ path: undefined as string | undefined, content: "", base: "", hash: null as string | null, loaded: false });
useEffect(() => {
let live = true;
if (!path) { setS({ path, content: "", base: "", hash: null, loaded: true }); return; }
setS((p) => ({ ...p, loaded: false }));
api("GET", coursePath(courseId, `/file?path=${encodeURIComponent(path)}`)).then(({ status, data }) => {
if (!live) return;
if (status === 200) setS({ path, content: data.content, base: data.content, hash: data.hash, loaded: true });
else setS({ path, content: initialIfMissing, base: "", hash: null, loaded: true });
});
return () => { live = false; };
}, [courseId, path, version]);
return {
path,
content: s.content,
loaded: s.loaded && s.path === path,
exists: s.hash !== null,
dirty: s.content !== s.base,
setContent: (c: string) => setS((p) => ({ ...p, content: c })),
write: (): FileWrite | null => (path ? { path, content: s.content, baseHash: s.hash } : null),
};
}
export type OpenFile = ReturnType<typeof useFile>;
export function useWorkspace(courseId: string) {
const [state, setState] = useState<CourseState | null>(null);
const [readOnly, setReadOnly] = useState<{ message: string; title: string; root: string } | null>(null);
const [error, setError] = useState<{ status: number; error?: string } | null>(null);
const [doc, setDocState] = useState<ManifestDoc | null>(null);
const [docDirty, setDocDirty] = useState(false);
const [version, setVersion] = useState(0);
const reload = useCallback(async () => {
const { status, data } = await api("GET", coursePath(courseId));
if (status !== 200) { setError({ status, error: data?.error }); return; }
setError(null);
if (data.readOnly) { setReadOnly({ message: data.message, title: data.course.title, root: data.course.root }); return; }
setState(data);
setDocState(data.manifestRaw ? clone(data.manifestRaw) : null);
setDocDirty(false);
}, [courseId]);
useEffect(() => { reload(); }, [reload]);
const ws: Workspace | null = state && doc ? {
courseId, state, doc, docDirty, version,
setDoc(update) {
setDocState((d) => {
const n = clone(d!);
update(n);
return n;
});
setDocDirty(true);
},
async save({ files = [], message, withManifest, manifest }) {
const body: Record<string, unknown> = { message, files };
if (manifest || withManifest === true || (withManifest !== false && docDirty)) {
body.manifest = manifest ?? doc;
body.manifestBaseHash = state.manifestHash;
}
const { status, data } = await api("PUT", coursePath(courseId, "/files"), body);
if (status === 200) {
setVersion((v) => v + 1);
await reload();
return { ok: true };
}
if (status === 409 && data?.conflicts) return { ok: false, conflicts: data.conflicts };
return { ok: false, error: data?.error ?? `Save failed (${status})` };
},
async upload(file, dir) {
const fd = new FormData();
fd.set("file", file);
fd.set("dir", dir);
const { status, data } = await api("POST", coursePath(courseId, "/upload"), fd);
if (status !== 200) return { ok: false, error: data?.error ?? `Upload failed (${status})` };
return { ok: true, path: data.path };
},
reload,
resolveUrl: (rel) => rawUrl(courseId, rel),
} : null;
return { ws, state, readOnly, error, reload, setVersion };
}
+12
View File
@@ -0,0 +1,12 @@
import type { WebFeature } from "@stc/contracts";
/** Studio web routes. The pages call /api/studio, which allows only owners, authors and per-course authors. */
export const studioWeb: WebFeature = {
name: "studio",
routes: [
{ path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } },
{ path: "/studio/:courseId", element: () => import("./ui/CourseStudio") },
],
};
export { setLessonPreview, type LessonPreviewProps, type LessonPreviewComponent } from "./ui/preview";
+14 -3
View File
@@ -15,9 +15,20 @@ export const users: Record<string, AuthUser> = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = []) {
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:") {
const dataDir = tempDir();
const db = await connect("sqlite://:memory:");
let dropSchema = async () => {};
if (dbUrl.startsWith("postgres")) {
// a private schema per test, like packages/db's forEachDb
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
const admin = await connect(dbUrl);
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
const u = new URL(dbUrl);
u.searchParams.set("options", `-c search_path=${schema}`);
dbUrl = u.toString();
dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); };
}
const db = await connect(dbUrl);
await migrate(db);
const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" };
const courses = await createCourseRepository(studioCourseDirs(config));
@@ -39,5 +50,5 @@ export async function setupApp(extraCourseDirs: string[] = []) {
try { json = JSON.parse(text); } catch { json = text; }
return { status: r.status, json };
};
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); } };
return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
}
+4 -2
View File
@@ -11,8 +11,10 @@ test("anonymous gets 401, learners 403", async () => {
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403);
});
test("creating a course records the creator in course_authors; a per-course author without the role can edit it", async () => {
env = await setupApp();
const DBS = [["sqlite", "sqlite://:memory:"], ...(process.env.STC_TEST_PG_URL ? [["postgres", process.env.STC_TEST_PG_URL]] : [])] as const;
test.each(DBS)("creating a course records the creator in course_authors; a per-course author without the role can edit it [%s]", async (_name, url) => {
env = await setupApp([], url);
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`;
expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]);
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { renderMarkdown } from "../src/ui/markdown";
test("headings, paragraphs, emphasis, inline code", () => {
expect(renderMarkdown("# Title\n\nSome **bold** and *em* and `x < y`.\nSecond line.")).toBe(
'<h1 id="title">Title</h1>\n<p>Some <strong>bold</strong> and <em>em</em> and <code>x &lt; y</code>.\nSecond line.</p>',
);
});
test("raw HTML is escaped, except the details/summary hint blocks", () => {
expect(renderMarkdown("<script>alert(1)</script>")).toBe("<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
const h = renderMarkdown("<details>\n<summary>Exercise 1 — nudge</summary>\n\nTry **this**.\n\n</details>");
expect(h).toMatch(/<details>\s*<summary>Exercise 1 — nudge<\/summary>/);
expect(h).toContain("<p>Try <strong>this</strong>.</p>");
expect(h).toContain("</details>");
expect(renderMarkdown("<summary onclick=x>a</summary>")).not.toContain("<summary onclick");
});
test("fenced code keeps content verbatim and escaped", () => {
expect(renderMarkdown("```js\nconst a = '<b>';\n# not a heading\n```")).toBe(
'<pre class="code"><code data-lang="js">const a = \'&lt;b&gt;\';\n# not a heading</code></pre>',
);
});
test("lists, quotes, rules", () => {
expect(renderMarkdown("- a\n- b\n\n1. one\n2. two")).toBe("<ul><li>a</li><li>b</li></ul>\n<ol><li>one</li><li>two</li></ol>");
expect(renderMarkdown("> quoted\n\n---")).toBe("<blockquote><p>quoted</p></blockquote>\n<hr>");
});
test("links: safe schemes only; images resolved through the given resolver", () => {
expect(renderMarkdown("[ok](https://x.org/a?b=1&c=2)")).toBe('<p><a href="https://x.org/a?b=1&amp;c=2">ok</a></p>');
expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("<p>bad</p>");
expect(renderMarkdown("![d](img/a b.png)", { resolveUrl: (u) => `/raw/${encodeURIComponent(u)}` })).toBe('<p><img src="/raw/img%2Fa%20b.png" alt="d"></p>');
});
test("front matter is not rendered", () => {
expect(renderMarkdown("---\ntitle: x\n---\n# H")).toBe('<h1 id="h">H</h1>');
});