Wires the whole Asset capability into the running application: the multipart upload transport (the contract runtime can only express JSON bodies), a single composition-root-owned CSRF provider shared between the platform's credential collaborator (18 JSON operations) and the upload transport (1 multipart operation), and Studio/StudioShell exposure of the Asset gateway alongside the existing document gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
6.3 KiB
TypeScript
205 lines
6.3 KiB
TypeScript
import {
|
|
type ReactNode,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
|
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
|
|
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
|
|
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
|
|
import type {
|
|
WorkingCopy,
|
|
WorkingCopyInput,
|
|
} from "../../contracts/studio/contract.ts";
|
|
import {
|
|
StudioContext,
|
|
type StudioContextValue,
|
|
type StudioEditorState,
|
|
type StudioEditorStatus,
|
|
} from "./use-studio.ts";
|
|
import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx";
|
|
import { useBeforeUnload } from "./components/use-before-unload.ts";
|
|
|
|
type StudioProviderProps = Readonly<{
|
|
children: ReactNode;
|
|
createGateway: () => StudioGateway;
|
|
// Optional so test harnesses that only exercise the document gateway keep
|
|
// working unchanged. `StudioShell` always supplies one in the running app.
|
|
createAssetGateway?: () => StudioAssetGateway;
|
|
resolvePublishedLabel?: ResolvePublishedLabel;
|
|
now?: () => Date;
|
|
navigate?: (href: string) => void;
|
|
}>;
|
|
|
|
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
|
const input: Record<string, unknown> = { ...document };
|
|
delete input.id;
|
|
delete input.version;
|
|
delete input.updatedAt;
|
|
return input as WorkingCopyInput;
|
|
}
|
|
|
|
function defaultNavigate(href: string): void {
|
|
window.history.pushState({}, "", href);
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
}
|
|
|
|
const missingPublishedLabel: ResolvePublishedLabel = () => undefined;
|
|
|
|
export function StudioProvider({
|
|
children,
|
|
createGateway,
|
|
createAssetGateway,
|
|
resolvePublishedLabel = missingPublishedLabel,
|
|
now = () => new Date("2026-08-14T01:00:00.000Z"),
|
|
navigate = defaultNavigate,
|
|
}: StudioProviderProps) {
|
|
const [gateway] = useState<StudioGateway>(() => createGateway());
|
|
// Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to
|
|
// the same provider generation in `StudioShell`, so a persisted `pageshow`
|
|
// remount recreates them together — never one without the other.
|
|
const [assetGateway] = useState<StudioAssetGateway | null>(
|
|
() => createAssetGateway?.() ?? null,
|
|
);
|
|
const [editor, setEditor] = useState<StudioEditorState | null>(null);
|
|
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
|
const [requestAnnouncement, setRequestAnnouncement] = useState("");
|
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
const saveSequence = useRef(0);
|
|
|
|
const unsafe = editor?.status === "DIRTY" || editor?.status === "CONFLICT";
|
|
useBeforeUnload(unsafe);
|
|
|
|
const navigateInternal = useCallback((
|
|
href: string,
|
|
trigger?: HTMLElement | null,
|
|
) => {
|
|
if (!unsafe) {
|
|
navigate(href);
|
|
return;
|
|
}
|
|
triggerRef.current = trigger ?? null;
|
|
setPendingHref(href);
|
|
setRequestAnnouncement("");
|
|
}, [navigate, unsafe]);
|
|
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
|
|
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
|
|
}, []);
|
|
const updateEditorDraft = useCallback((draft: WorkingCopyInput) => {
|
|
setEditor((current) => current ? { ...current, draft, status: "DIRTY" } : current);
|
|
}, []);
|
|
const setEditorStatus = useCallback((status: StudioEditorStatus) => {
|
|
setEditor((current) => current ? { ...current, status } : current);
|
|
}, []);
|
|
const clearEditor = useCallback(() => setEditor(null), []);
|
|
|
|
const stay = useCallback(() => {
|
|
const trigger = triggerRef.current;
|
|
triggerRef.current = null;
|
|
setPendingHref(null);
|
|
setRequestAnnouncement("");
|
|
queueMicrotask(() => trigger?.focus());
|
|
}, []);
|
|
|
|
const discard = useCallback(() => {
|
|
const href = pendingHref;
|
|
setEditor((current) => current
|
|
? { ...current, draft: inputOf(current.saved), status: "CLEAN" }
|
|
: current);
|
|
setPendingHref(null);
|
|
setRequestAnnouncement("");
|
|
triggerRef.current = null;
|
|
if (href) navigate(href);
|
|
}, [navigate, pendingHref]);
|
|
|
|
const saveThenNavigate = useCallback(async (): Promise<boolean> => {
|
|
const current = editor;
|
|
const href = pendingHref;
|
|
if (!current || !href || current.status === "SAVING") return false;
|
|
setEditor({ ...current, status: "SAVING" });
|
|
try {
|
|
const detail = await gateway.saveDocument(
|
|
current.documentId,
|
|
{
|
|
expectedVersion: current.saved.version,
|
|
document: current.draft,
|
|
},
|
|
{ idempotencyKey: `studio-local-save-${++saveSequence.current}` },
|
|
);
|
|
setEditor({
|
|
documentId: detail.document.id,
|
|
saved: detail.document,
|
|
draft: inputOf(detail.document),
|
|
status: "CLEAN",
|
|
});
|
|
setPendingHref(null);
|
|
setRequestAnnouncement(`버전 ${detail.document.version}으로 저장했습니다.`);
|
|
triggerRef.current = null;
|
|
navigate(href);
|
|
return true;
|
|
} catch (error) {
|
|
setEditor({
|
|
...current,
|
|
status: isStudioGatewayError(error) && error.code === "VERSION_CONFLICT"
|
|
? "CONFLICT"
|
|
: "DIRTY",
|
|
});
|
|
setRequestAnnouncement(
|
|
isStudioGatewayError(error)
|
|
? error.problem.detail
|
|
: "저장하지 못했습니다. 다시 시도해 주세요.",
|
|
);
|
|
return false;
|
|
}
|
|
}, [editor, gateway, navigate, pendingHref]);
|
|
|
|
const value = useMemo<StudioContextValue>(
|
|
() => ({
|
|
gateway,
|
|
assetGateway,
|
|
resolvePublishedLabel,
|
|
now,
|
|
editor,
|
|
requestAnnouncement,
|
|
setRequestAnnouncement,
|
|
navigateInternal,
|
|
beginEditor,
|
|
updateEditorDraft,
|
|
setEditorStatus,
|
|
clearEditor,
|
|
}),
|
|
[
|
|
assetGateway,
|
|
beginEditor,
|
|
clearEditor,
|
|
editor,
|
|
gateway,
|
|
navigateInternal,
|
|
now,
|
|
requestAnnouncement,
|
|
resolvePublishedLabel,
|
|
setEditorStatus,
|
|
updateEditorDraft,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<StudioContext.Provider value={value}>
|
|
<div className="studio-app">
|
|
{children}
|
|
<UnsavedLeaveDialog
|
|
open={pendingHref !== null}
|
|
saving={editor?.status === "SAVING"}
|
|
message={requestAnnouncement}
|
|
onStay={stay}
|
|
onDiscard={discard}
|
|
onSaveThenNavigate={saveThenNavigate}
|
|
/>
|
|
</div>
|
|
</StudioContext.Provider>
|
|
);
|
|
}
|