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:
co-authored by
Claude Opus 5.5
parent
497ce91216
commit
0992df7257
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const unesc = (s: string) => s.replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/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} />;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(); } };
|
||||
}
|
||||
|
||||
@@ -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 < y</code>.\nSecond line.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test("raw HTML is escaped, except the details/summary hint blocks", () => {
|
||||
expect(renderMarkdown("<script>alert(1)</script>")).toBe("<p><script>alert(1)</script></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 = \'<b>\';\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&c=2">ok</a></p>');
|
||||
expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("<p>bad</p>");
|
||||
expect(renderMarkdown("", { 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>');
|
||||
});
|
||||
Reference in New Issue
Block a user