From 0992df7257d7c2466635a0e8fffce9a9da5cf2bd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:26:18 +0100 Subject: [PATCH] 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 --- apps/web/src/features.ts | 1 + packages/feature-studio/dev/serve.ts | 35 ++++ .../feature-studio/src/ui/CourseStudio.tsx | 163 +++++++++++++++++ packages/feature-studio/src/ui/StudioHome.tsx | 119 +++++++++++++ packages/feature-studio/src/ui/api.ts | 58 ++++++ .../src/ui/editors/CourseSettings.tsx | 85 +++++++++ .../src/ui/editors/GitPanels.tsx | 123 +++++++++++++ .../src/ui/editors/LessonEditor.tsx | 168 ++++++++++++++++++ .../feature-studio/src/ui/editors/Media.tsx | 87 +++++++++ .../feature-studio/src/ui/editors/common.tsx | 131 ++++++++++++++ packages/feature-studio/src/ui/markdown.ts | 129 ++++++++++++++ packages/feature-studio/src/ui/preview.tsx | 28 +++ packages/feature-studio/src/ui/studio.css | 98 ++++++++++ packages/feature-studio/src/ui/workspace.ts | 110 ++++++++++++ packages/feature-studio/src/web.ts | 12 ++ packages/feature-studio/test/app-helpers.ts | 17 +- packages/feature-studio/test/feature.test.ts | 6 +- packages/feature-studio/test/markdown.test.ts | 38 ++++ 18 files changed, 1403 insertions(+), 5 deletions(-) create mode 100644 packages/feature-studio/dev/serve.ts create mode 100644 packages/feature-studio/src/ui/CourseStudio.tsx create mode 100644 packages/feature-studio/src/ui/StudioHome.tsx create mode 100644 packages/feature-studio/src/ui/api.ts create mode 100644 packages/feature-studio/src/ui/editors/CourseSettings.tsx create mode 100644 packages/feature-studio/src/ui/editors/GitPanels.tsx create mode 100644 packages/feature-studio/src/ui/editors/LessonEditor.tsx create mode 100644 packages/feature-studio/src/ui/editors/Media.tsx create mode 100644 packages/feature-studio/src/ui/editors/common.tsx create mode 100644 packages/feature-studio/src/ui/markdown.ts create mode 100644 packages/feature-studio/src/ui/preview.tsx create mode 100644 packages/feature-studio/src/ui/studio.css create mode 100644 packages/feature-studio/src/ui/workspace.ts create mode 100644 packages/feature-studio/src/web.ts create mode 100644 packages/feature-studio/test/markdown.test.ts diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 8b077c9..cc95268 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -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); diff --git a/packages/feature-studio/dev/serve.ts b/packages/feature-studio/dev/serve.ts new file mode 100644 index 0000000..ef20f0e --- /dev/null +++ b/packages/feature-studio/dev/serve.ts @@ -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"}`); diff --git a/packages/feature-studio/src/ui/CourseStudio.tsx b/packages/feature-studio/src/ui/CourseStudio.tsx new file mode 100644 index 0000000..c5b1cbb --- /dev/null +++ b/packages/feature-studio/src/ui/CourseStudio.tsx @@ -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

Studio

{error.status === 404 ?
No such course.
: }
; + if (readOnly) { + return ( +
+

Studio

+

{readOnly.title}

+
{readOnly.message}. It is read-only here; edit it in its own repository at {readOnly.root}.
+
+ ); + } + if (!ws) return

Loading…

; + return ; +} + +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(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 ( +
+ + +
+
+
+ Editing your draft {st.branch} + {" · "}{st.ahead ? {st.ahead} unpublished {st.ahead === 1 ? "change" : "changes"} : "nothing unpublished"} + {st.behind ? <> · {st.behind} new on published since your draft : null} + {ws.docDirty ? <> · unsaved structure changes : null} +
+ {st.behind && !st.merging ? Update draft : null} + Publish +
+ + {notice ? ( +
+ {notice.text} + {notice.files?.length ?
    {notice.files.map((f) =>
  • {f}
  • )}
: null} + {notice.diagnostics?.length ?
    {notice.diagnostics.map((d, i) =>
  • {d.lessonId ? `${d.lessonId}: ` : ""}{d.message}
  • )}
: null} + {notice.offerUpdate ?
Update draft and resolve
: null} +
+ ) : null} + + {view === "conflicts" ? + : view === "lesson" && lessonId ? setParams({ view: "settings" })} /> + : view === "decks" ? + : view === "attachments" ? + : view === "manifest" ? + : view === "sync" ? + : } +
+
+ ); +} + +/** 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("text"); + const [err, setErr] = useState(null); + if (!open) return ; + 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 ( +
+ setTitle(e.target.value)} /> + + + + {err ? {err} : null} +
Add setOpen(false)}>Cancel
+
+ ); +} diff --git a/packages/feature-studio/src/ui/StudioHome.tsx b/packages/feature-studio/src/ui/StudioHome.tsx new file mode 100644 index 0000000..0a68b8c --- /dev/null +++ b/packages/feature-studio/src/ui/StudioHome.tsx @@ -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
Sign in to use the studio. The studio is for course authors and the owner.
; + if (status === 403) return
The studio is for authors. Ask the owner for author access.
; + return
Something went wrong. {error}
; +} + +export default function StudioHome() { + const [rows, setRows] = useState(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(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 ( +
+

Studio

+

Your courses

+

Edit on your own draft, save as you go, and publish when it is ready. Learners only see what you publish.

+ {err ? : null} + + {canCreate ? ( +
+

New course

+
+ + + Create course +
+ {createErr ?

{createErr}

: null} +
+ ) : null} + + {rows ? ( +
+

Studio courses

+ {managed.length === 0 ?

No studio courses yet.

: ( +
    + {managed.map((c) => ( +
  • + +
    + {c.title} + {c.id}{c.pitch ? ` · ${c.pitch}` : ""} +
    + {c.merging ? Resolving conflicts : null} + {c.ahead ? {c.ahead} unpublished : null} + {c.behind ? {c.behind} new on published : null} + {c.remote ? Remote : null} + {c.publishedStatus === "error" ? Published version has errors : null} +
  • + ))} +
+ )} +
+ ) : null} + + {outside.length ? ( +
+

Managed outside STCourses

+

These courses come from repositories the platform does not own. Edit them in their own repository.

+
    + {outside.map((c) => ( +
  • + +
    + {c.title} + {c.id} · {c.message} +
    + Read-only +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/packages/feature-studio/src/ui/api.ts b/packages/feature-studio/src/ui/api.ts new file mode 100644 index 0000000..8efb89f --- /dev/null +++ b/packages/feature-studio/src/ui/api.ts @@ -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(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)["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; + [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) => { + let id = slug(base); + for (let n = 2; taken.has(id); n++) id = `${slug(base)}-${n}`; + return id; +}; diff --git a/packages/feature-studio/src/ui/editors/CourseSettings.tsx b/packages/feature-studio/src/ui/editors/CourseSettings.tsx new file mode 100644 index 0000000..6aae94b --- /dev/null +++ b/packages/feature-studio/src/ui/editors/CourseSettings.tsx @@ -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: K, v: (typeof doc)[K] | undefined) => setDoc((d) => { + if (v === undefined || v === "") delete d[k]; else d[k] = v; + }); + + return ( +
+

Course settings

+

{doc.title || "Untitled course"}

+

These fields live in stcourse.yaml. Changes go to your draft when you save.

+ +
+ set("title", e.target.value)} /> + +