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:
co-authored by
Claude Opus 5.5
parent
afa2338ad3
commit
20246b442f
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user