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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
497ce91216
commit
0992df7257
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user