111 lines
4.8 KiB
TypeScript
111 lines
4.8 KiB
TypeScript
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 };
|
|
}
|