feat: port TechLog Studio validation workflow

This commit is contained in:
DongHyeonka
2026-08-15 23:59:08 +09:00
parent 5933265975
commit 9c6906fc6f
20 changed files with 1551 additions and 35 deletions
@@ -2,9 +2,11 @@ import {
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type {
WorkingCopy,
@@ -16,13 +18,24 @@ import {
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;
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"));
@@ -31,12 +44,31 @@ function defaultNavigate(href: string): void {
export function StudioProvider({
children,
createGateway,
now = () => new Date("2026-08-14T01:00:00.000Z"),
navigate = defaultNavigate,
}: StudioProviderProps) {
const [gateway] = useState<StudioGateway>(() => createGateway());
const [editor, setEditor] = useState<StudioEditorState | null>(null);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [requestAnnouncement, setRequestAnnouncement] = useState("");
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
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" });
}, []);
@@ -47,9 +79,71 @@ export function StudioProvider({
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,
now,
editor,
requestAnnouncement,
setRequestAnnouncement,
@@ -65,6 +159,7 @@ export function StudioProvider({
editor,
gateway,
navigateInternal,
now,
requestAnnouncement,
setEditorStatus,
updateEditorDraft,
@@ -73,7 +168,17 @@ export function StudioProvider({
return (
<StudioContext.Provider value={value}>
<div className="studio-app">{children}</div>
<div className="studio-app">
{children}
<UnsavedLeaveDialog
open={pendingHref !== null}
saving={editor?.status === "SAVING"}
message={requestAnnouncement}
onStay={stay}
onDiscard={discard}
onSaveThenNavigate={saveThenNavigate}
/>
</div>
</StudioContext.Provider>
);
}