From 20246b442fd4cce0f5b630b7b04f7741023d7065 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:33:55 +0100 Subject: [PATCH] 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 --- .../feature-studio/src/ui/ConvertPanel.tsx | 74 +++++++++++++++++++ .../feature-studio/src/ui/CourseStudio.tsx | 5 +- packages/feature-studio/src/ui/StudioHome.tsx | 6 +- packages/feature-studio/src/ui/api.ts | 2 +- packages/feature-studio/src/ui/studio.css | 5 ++ packages/feature-studio/src/ui/workspace.ts | 6 +- .../feature-studio/test/convert-ui.test.tsx | 38 ++++++++++ 7 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 packages/feature-studio/src/ui/ConvertPanel.tsx create mode 100644 packages/feature-studio/test/convert-ui.test.tsx diff --git a/packages/feature-studio/src/ui/ConvertPanel.tsx b/packages/feature-studio/src/ui/ConvertPanel.tsx new file mode 100644 index 0000000..fd215e0 --- /dev/null +++ b/packages/feature-studio/src/ui/ConvertPanel.tsx @@ -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 ( +
+

Make editable in the studio

+

+ 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. +

+ {running ? ( +
+ + {phase.message ?? "Waiting to start…"} +
+ ) : null} + {phase.kind === "failed" ?

{phase.error}

: null} +
+ {phase.kind === "failed" ? "Try again" : "Make editable in the studio"} +
+
+ ); +} + +/** 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({ 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 ; +} + +/** 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 ( + <> +
+ Imported course.{" "} + {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."} +
+ {info.convertible ? : null} + + ); + } + return
{info.message}. It is read-only here; edit it in its own repository at {info.root}.
; +} diff --git a/packages/feature-studio/src/ui/CourseStudio.tsx b/packages/feature-studio/src/ui/CourseStudio.tsx index c5b1cbb..f48a104 100644 --- a/packages/feature-studio/src/ui/CourseStudio.tsx +++ b/packages/feature-studio/src/ui/CourseStudio.tsx @@ -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

Studio

{error.status === 404 ?
No such course.
: }
; if (readOnly) { @@ -23,7 +24,7 @@ export default function CourseStudio() {

Studio

{readOnly.title}

-
{readOnly.message}. It is read-only here; edit it in its own repository at {readOnly.root}.
+
); } diff --git a/packages/feature-studio/src/ui/StudioHome.tsx b/packages/feature-studio/src/ui/StudioHome.tsx index 0a68b8c..2c21710 100644 --- a/packages/feature-studio/src/ui/StudioHome.tsx +++ b/packages/feature-studio/src/ui/StudioHome.tsx @@ -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 ? (

Managed outside STCourses

-

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

+

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.

    {outside.map((c) => (
  • @@ -108,7 +108,7 @@ export default function StudioHome() { {c.title} {c.id} · {c.message} - Read-only + {c.convertible ? Make editable : {c.imported ? "Imported" : "Read-only"}}
  • ))}
diff --git a/packages/feature-studio/src/ui/api.ts b/packages/feature-studio/src/ui/api.ts index 8efb89f..b0fad74 100644 --- a/packages/feature-studio/src/ui/api.ts +++ b/packages/feature-studio/src/ui/api.ts @@ -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 } diff --git a/packages/feature-studio/src/ui/studio.css b/packages/feature-studio/src/ui/studio.css index c1ab02a..bb864b9 100644 --- a/packages/feature-studio/src/ui/studio.css +++ b/packages/feature-studio/src/ui/studio.css @@ -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; } diff --git a/packages/feature-studio/src/ui/workspace.ts b/packages/feature-studio/src/ui/workspace.ts index ec80f89..cd7b8e8 100644 --- a/packages/feature-studio/src/ui/workspace.ts +++ b/packages/feature-studio/src/ui/workspace.ts @@ -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; export function useWorkspace(courseId: string) { const [state, setState] = useState(null); - const [readOnly, setReadOnly] = useState<{ message: string; title: string; root: string } | null>(null); + const [readOnly, setReadOnly] = useState(null); const [error, setError] = useState<{ status: number; error?: string } | null>(null); const [doc, setDocState] = useState(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); diff --git a/packages/feature-studio/test/convert-ui.test.tsx b/packages/feature-studio/test/convert-ui.test.tsx new file mode 100644 index 0000000..a26d303 --- /dev/null +++ b/packages/feature-studio/test/convert-ui.test.tsx @@ -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(); + 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(" { + const running = renderToString(); + 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(); + 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(); + expect(owner).toContain("Make editable in the studio"); + expect(owner).not.toContain("/d/courses/imp"); + const author = renderToString(); + expect(author).not.toContain("); + expect(outside).toContain("/repos/o"); + expect(outside).not.toContain("