Files
tech-log-frontend/src/features/tech-log/presentation/studio/studio-provider.tsx
T
DongHyeonkaandClaude Opus 5 c9c832c365 feat: add the TechLog asset multipart upload transport
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>
2026-08-18 02:39:51 +09:00

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>
);
}