feat(studio): "Make editable in the studio" panel for imported courses

Owner sees what conversion does, starts it, follows progress and lands in the
workbench; other authors are told only the owner can. Existing tokens only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 20:33:55 +01:00
co-authored by Claude Opus 5.5
parent afa2338ad3
commit 20246b442f
7 changed files with 128 additions and 8 deletions
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { Pill, ProgressBar } from "@stc/ui";
import { api, coursePath } from "./api";
export type ConvertPhase =
| { kind: "idle" }
| { kind: "running"; progress: number; message: string | null }
| { kind: "failed"; error: string };
export interface ReadOnlyInfo { title: string; message: string; root: string; imported?: boolean; convertible?: boolean }
/** The owner's "Make editable in the studio" panel for an imported course (v1.1 S). Presentational. */
export function ConvertPanelView({ phase, onConvert }: { phase: ConvertPhase; onConvert: () => void }) {
const running = phase.kind === "running";
return (
<section className="studio-section" aria-labelledby="convert-head">
<h2 id="convert-head">Make editable in the studio</h2>
<p className="studio-lede" style={{ margin: "0 0 12px" }}>
The course becomes a studio course with the same id, so learners keep their progress and notes. The manifest,
lessons and small files go into a git repository you edit on a draft and publish. Videos and other large files
stay outside git, on this server, and keep playing at the same paths. Visibility does not change.
</p>
{running ? (
<div className="studio-convert-progress" aria-live="polite">
<ProgressBar value={phase.progress} label="Conversion progress" />
<span className="faint">{phase.message ?? "Waiting to start…"}</span>
</div>
) : null}
{phase.kind === "failed" ? <p className="studio-errors" role="alert">{phase.error}</p> : null}
<div className="studio-actions">
<Pill variant="primary" disabled={running} onClick={onConvert}>{phase.kind === "failed" ? "Try again" : "Make editable in the studio"}</Pill>
</div>
</section>
);
}
/** Starts the conversion job and polls it; calls `onDone` once the course is a studio course. */
export function ConvertPanel({ courseId, onDone }: { courseId: string; onDone: () => void }) {
const [phase, setPhase] = useState<ConvertPhase>({ kind: "idle" });
const live = useRef(true);
useEffect(() => () => { live.current = false; }, []);
const start = async () => {
setPhase({ kind: "running", progress: 0, message: null });
const { status, data } = await api("POST", coursePath(courseId, "/convert"));
if (status !== 202) return setPhase({ kind: "failed", error: data?.error ?? `Could not start (${status})` });
for (;;) {
await new Promise((r) => setTimeout(r, 700));
if (!live.current) return;
const j = await api("GET", `/api/studio/jobs/${encodeURIComponent(data.jobId)}`);
if (j.status !== 200) return setPhase({ kind: "failed", error: j.data?.error ?? `Could not follow the conversion (${j.status})` });
if (j.data.status === "done") return onDone();
if (j.data.status === "failed") return setPhase({ kind: "failed", error: j.data.error ?? "The conversion failed" });
setPhase({ kind: "running", progress: j.data.progress ?? 0, message: j.data.message });
}
};
return <ConvertPanelView phase={phase} onConvert={start} />;
}
/** What the studio shows for a course it does not manage. */
export function ReadOnlyView({ courseId, info, onConverted }: { courseId: string; info: ReadOnlyInfo; onConverted: () => void }) {
if (info.imported) {
return (
<>
<div className="studio-note studio-note--warn" role="note">
<strong>Imported course.</strong>{" "}
{info.convertible ? "It is read-only until you make it editable in the studio." : "It is read-only here. Only the owner can make it editable in the studio."}
</div>
{info.convertible ? <ConvertPanel courseId={courseId} onDone={onConverted} /> : null}
</>
);
}
return <div className="studio-note studio-note--warn" role="note"><strong>{info.message}.</strong> It is read-only here; edit it in its own repository at <code>{info.root}</code>.</div>;
}
@@ -3,6 +3,7 @@ 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 { ReadOnlyView } from "./ConvertPanel";
import { clone, useWorkspace, type Workspace } from "./workspace";
import { CourseSettings } from "./editors/CourseSettings";
import { LessonEditor } from "./editors/LessonEditor";
@@ -15,7 +16,7 @@ type Notice = { kind: "ok" | "warn" | "bad"; text: string; files?: string[]; dia
export default function CourseStudio() {
const { courseId = "" } = useParams();
const { ws, readOnly, error } = useWorkspace(courseId);
const { ws, readOnly, error, reload } = 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) {
@@ -23,7 +24,7 @@ export default function CourseStudio() {
<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>
<ReadOnlyView courseId={courseId} info={readOnly} onConverted={reload} />
</div>
);
}
@@ -6,7 +6,7 @@ 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;
ahead?: number; behind?: number; merging?: boolean; remote?: string | null; message?: string; imported?: boolean; convertible?: boolean;
}
export function AccessMessage({ status, error }: { status: number; error?: string }) {
@@ -99,7 +99,7 @@ export default function StudioHome() {
{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>
<p className="faint" style={{ margin: 0 }}>These courses come from repositories the platform does not own, or from imports. Edit them in their own repository; the owner can make an imported course editable here.</p>
<ul className="studio-rows">
{outside.map((c) => (
<li className="studio-row" key={c.id}>
@@ -108,7 +108,7 @@ export default function StudioHome() {
<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>
{c.convertible ? <Link className="studio-tag studio-tag--accent" to={`/studio/${encodeURIComponent(c.id)}`}>Make editable</Link> : <span className="studio-tag">{c.imported ? "Imported" : "Read-only"}</span>}
</li>
))}
</ul>
+1 -1
View File
@@ -45,7 +45,7 @@ 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 ReadOnlyState { readOnly: true; message: string; imported?: boolean; convertible?: boolean; 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 }
@@ -96,3 +96,8 @@
.studio-content { padding: 0 16px 48px; width: 100%; }
.studio-bar { margin: 0 -16px 20px; padding: 10px 16px; }
}
/* "Make editable in the studio" (imported courses) */
.studio-convert-progress { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; max-width: 760px; }
.studio-convert-progress .stc-bar { flex: 1; }
a.studio-tag { text-decoration: none; }
+4 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { ReadOnlyInfo } from "./ConvertPanel";
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 };
@@ -51,7 +52,7 @@ 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 [readOnly, setReadOnly] = useState<ReadOnlyInfo | null>(null);
const [error, setError] = useState<{ status: number; error?: string } | null>(null);
const [doc, setDocState] = useState<ManifestDoc | null>(null);
const [docDirty, setDocDirty] = useState(false);
@@ -61,7 +62,8 @@ export function useWorkspace(courseId: string) {
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; }
if (data.readOnly) { setReadOnly({ message: data.message, title: data.course.title, root: data.course.root, imported: !!data.imported, convertible: !!data.convertible }); return; }
setReadOnly(null);
setState(data);
setDocState(data.manifestRaw ? clone(data.manifestRaw) : null);
setDocDirty(false);
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { ConvertPanelView, ReadOnlyView } from "../src/ui/ConvertPanel";
const noop = () => {};
test("the owner sees the action and what it does", () => {
const html = renderToString(<ConvertPanelView phase={{ kind: "idle" }} onConvert={noop} />);
expect(html).toContain("Make editable in the studio");
expect(html).toContain("same id");
expect(html).toContain("progress and notes");
expect(html).toContain("outside git");
expect(html).toContain("<button");
});
test("progress and failure states", () => {
const running = renderToString(<ConvertPanelView phase={{ kind: "running", progress: 0.4, message: "Checksumming media/a.mp4" }} onConvert={noop} />);
expect(running).toContain('role="progressbar"');
expect(running).toContain('aria-valuenow="40"');
expect(running).toContain("Checksumming media/a.mp4");
expect(running).toContain("disabled");
const failed = renderToString(<ConvertPanelView phase={{ kind: "failed", error: "the imported manifest is invalid" }} onConvert={noop} />);
expect(failed).toContain('role="alert"');
expect(failed).toContain("the imported manifest is invalid");
expect(failed).toContain("Try again");
});
test("read-only view: imported for the owner offers the action; for others explains who can", () => {
const owner = renderToString(<ReadOnlyView courseId="imp" info={{ title: "Imp", message: "Imported course", root: "/d/courses/imp", imported: true, convertible: true }} onConverted={noop} />);
expect(owner).toContain("Make editable in the studio");
expect(owner).not.toContain("/d/courses/imp");
const author = renderToString(<ReadOnlyView courseId="imp" info={{ title: "Imp", message: "Imported course", root: "/d/courses/imp", imported: true, convertible: false }} onConverted={noop} />);
expect(author).not.toContain("<button");
expect(author).toContain("Only the owner");
const outside = renderToString(<ReadOnlyView courseId="o" info={{ title: "O", message: "This course is managed outside STCourses", root: "/repos/o", imported: false, convertible: false }} onConverted={noop} />);
expect(outside).toContain("/repos/o");
expect(outside).not.toContain("<button");
});