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
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import type { components } from "../../../contracts/studio/generated.ts";
@@ -7,7 +6,9 @@ import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -24,7 +25,7 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const session = useStudioEditorSession();
const { begin, clear, editor, updateDraft } = session;
const { begin, clear, editor, setStatus, updateDraft } = session;
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${documentId}:${retryKey}`;
const [result, setResult] = useState<{
@@ -66,6 +67,40 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
return () => request.abort();
}, [begin, clear, documentId, requestKey, studio.gateway]);
const save = useCallback(async () => {
const current = editor;
if (
!current ||
current.status === "CLEAN" ||
current.status === "SAVING" ||
current.status === "CONFLICT"
) return;
setStatus("SAVING");
try {
const detail = await studio.gateway.saveDocument(
current.documentId,
{
expectedVersion: current.saved.version,
document: current.draft,
},
{ idempotencyKey: createLocalId("studio-editor-save") },
);
begin(detail.document, inputOf(detail.document));
studio.setRequestAnnouncement(
`버전 ${detail.document.version}으로 저장했습니다.`,
);
} catch (error) {
const conflict = isStudioGatewayError(error) &&
error.code === "VERSION_CONFLICT";
setStatus(conflict ? "CONFLICT" : "DIRTY");
studio.setRequestAnnouncement(
isStudioGatewayError(error)
? error.problem.detail
: "저장하지 못했습니다.",
);
}
}, [begin, editor, setStatus, studio]);
const controller = useMemo<DocumentEditorController | null>(() => {
if (!editor || editor.documentId !== documentId) return null;
return {
@@ -78,8 +113,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
replace(draft) {
if (draft.kind === editor.draft.kind) updateDraft(draft);
},
save,
};
}, [documentId, editor, updateDraft]);
}, [documentId, editor, save, updateDraft]);
const currentResult = result?.key === requestKey ? result : null;
const problem = currentResult?.problem ?? null;
@@ -92,7 +128,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
<p className="studio-eyebrow">404</p>
<h1>Studio </h1>
<p> Studio .</p>
<Link to="/studio/documents"> </Link>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
@@ -21,7 +21,7 @@ export type DocumentEditorController = {
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save?: () => Promise<void>;
save(): Promise<void>;
};
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
@@ -1,9 +1,9 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const kindLabel = {
CASE: "Case",
@@ -94,9 +94,9 @@ export function DocumentList() {
Case, Reference, Question을 .
</p>
</div>
<Link className="studio-primary-action" to="/studio/documents/new">
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
</Link>
</GuardedStudioLink>
</header>
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
<form role="search" onSubmit={submitSearch}>
@@ -174,9 +174,9 @@ export function DocumentList() {
<p className="studio-row-label">{kindLabel[item.kind]}</p>
<div className="studio-document-title">
<h2>
<Link to={`/studio/documents/${item.id}/edit`}>
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
{item.title || "제목 없는 작업본"}
</Link>
</GuardedStudioLink>
</h2>
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
</div>
@@ -207,7 +207,9 @@ export function DocumentList() {
<section className="studio-empty-state">
<h2> </h2>
<p> .</p>
<Link to="/studio/documents/new"> </Link>
<GuardedStudioLink href="/studio/documents/new">
</GuardedStudioLink>
</section>
)}
{page.nextCursor ? (
@@ -1,6 +1,5 @@
import { Link } from "react-router-dom";
import type { DocumentEditorController } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const labels = {
CLEAN: "저장됨",
@@ -16,8 +15,8 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{controller.draft.kind}</dd></div></dl>
<button type="button" onClick={() => { if (controller.save) void controller.save(); }} disabled={!controller.save || controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
<Link className="studio-editor-next-link" to={`/studio/documents/${controller.saved.id}/validation`}> </Link>
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}> </GuardedStudioLink>
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert"> . .</p> : <p> . .</p>}
</aside>
);
@@ -0,0 +1,52 @@
import type {
AnchorHTMLAttributes,
MouseEvent as ReactMouseEvent,
} from "react";
import { useStudio } from "../use-studio.ts";
type GuardedStudioLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
};
function isPlainPrimaryClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
return event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey;
}
export function GuardedStudioLink({
href,
onClick,
...props
}: GuardedStudioLinkProps) {
const { navigateInternal } = useStudio();
return (
<a
{...props}
href={href}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || !isPlainPrimaryClick(event)) return;
if (
event.currentTarget.hasAttribute("download") ||
(event.currentTarget.target && event.currentTarget.target !== "_self")
) return;
if (href.startsWith("#")) return;
const target = new URL(href, window.location.href);
if (target.origin !== window.location.origin) return;
if (
target.pathname !== "/studio" &&
!target.pathname.startsWith("/studio/")
) return;
event.preventDefault();
navigateInternal(
`${target.pathname}${target.search}${target.hash}`,
event.currentTarget,
);
}}
/>
);
}
@@ -0,0 +1,303 @@
import { useEffect, useRef, useState } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type {
PreviewDetail,
WorkingCopyDetail,
} from "../../../contracts/studio/contract.ts";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
type LoadedPreview = {
detail: WorkingCopyDetail;
preview: PreviewDetail | null;
};
function isAbortError(error: unknown): boolean {
return typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError";
}
function usableValidation(detail: WorkingCopyDetail, now: Date) {
const validation = detail.currentValidation;
return validation &&
deriveValidationState({ ...detail, now }).freshness === "CURRENT" &&
validation.status !== "INVALID"
? validation
: null;
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(date);
}
function resolvePreviewEvidenceAsset(key: string) {
if (key !== "fetch-strategy-boundary") {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
};
}
export function PublicPreviewScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const [retryKey, setRetryKey] = useState(0);
const [loaded, setLoaded] = useState<LoadedPreview | null>(null);
const [problem, setProblem] = useState<StudioGatewayError | Error | null>(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [feedback, setFeedback] = useState("");
const sequence = useRef(0);
useEffect(() => {
const request = new AbortController();
void Promise.all([
studio.gateway.getDocument(documentId, { signal: request.signal }),
studio.gateway.getCurrentPreview(
documentId,
{ signal: request.signal },
).catch((error: unknown) => {
if (
isStudioGatewayError(error) &&
error.code === "PREVIEW_NOT_FOUND"
) return null;
throw error;
}),
]).then(
([detail, preview]) => {
if (request.signal.aborted) return;
setLoaded({ detail, preview });
setLoading(false);
},
(error: unknown) => {
if (request.signal.aborted || isAbortError(error)) return;
setProblem(
error instanceof Error
? error
: new Error("Public Preview를 불러오지 못했습니다."),
);
setLoading(false);
},
);
return () => request.abort();
}, [documentId, retryKey, studio.gateway]);
const retry = () => {
setLoading(true);
setProblem(null);
setRetryKey((value) => value + 1);
};
const createPreview = async () => {
if (!loaded || creating) return;
const validation = usableValidation(loaded.detail, studio.now());
if (!validation) return;
setCreating(true);
setFeedback("");
try {
const preview = await studio.gateway.createPreview(
documentId,
{
expectedVersion: loaded.detail.document.version,
validationId: validation.validationId,
},
{
idempotencyKey: createLocalId(
`studio-preview-${++sequence.current}`,
),
},
);
setLoaded((current) => current
? {
...current,
preview: {
preview,
state: "CURRENT",
currentDocumentVersion: current.detail.document.version,
currentValidationId: validation.validationId,
},
}
: current);
setFeedback("Public Preview를 만들었습니다.");
studio.setRequestAnnouncement(
"현재 저장 버전의 Public Preview를 만들었습니다.",
);
} catch (error) {
if (isAbortError(error)) return;
setFeedback(
isStudioGatewayError(error)
? error.problem.detail
: "Public Preview를 만들지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setCreating(false);
}
};
if (loading) {
return (
<p className="studio-workflow-loading" role="status">
Public Preview를 .
</p>
);
}
if (
problem &&
isStudioGatewayError(problem) &&
problem.code === "DOCUMENT_NOT_FOUND"
) {
return (
<section className="studio-route-state">
<p className="studio-eyebrow">404</p>
<h1> </h1>
<p> Studio .</p>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
if (problem || !loaded) {
return (
<section className="studio-route-state" role="alert">
<p className="studio-eyebrow">PREVIEW ERROR</p>
<h1>Public Preview를 </h1>
<p>
{problem && isStudioGatewayError(problem)
? problem.problem.detail
: problem?.message}
</p>
<button type="button" onClick={retry}> </button>
</section>
);
}
const validation = usableValidation(loaded.detail, studio.now());
const previewDetail = loaded.preview;
const state = previewDetail?.state ?? "NONE";
const message = state === "CURRENT"
? "현재 저장 버전의 Public Preview입니다."
: state === "STALE"
? "저장본보다 이전에 만든 Public Preview입니다."
: state === "EXPIRED"
? "이 Public Preview는 만료되었습니다."
: "아직 만든 Public Preview가 없습니다";
const canCreate = Boolean(validation) &&
state !== "CURRENT" &&
state !== "EXPIRED";
return (
<div className="studio-preview-screen">
<header
className={`studio-preview-toolbar ${previewDetail ? "studio-preview-toolbar--compact" : ""}`}
>
<div>
<p className="studio-eyebrow">
PUBLIC PREVIEW · VERSION {loaded.detail.document.version}
</p>
{previewDetail ? (
<p className="studio-preview-title">Public Preview</p>
) : (
<h1>Public Preview</h1>
)}
<p>{loaded.detail.document.title}</p>
</div>
<GuardedStudioLink
className="studio-workflow-quiet-link"
href={`/studio/documents/${documentId}/validation`}
>
</GuardedStudioLink>
</header>
<section
className={`studio-preview-state studio-preview-state--${state.toLowerCase()}`}
aria-label="Public Preview 상태"
>
<div><strong>{state}</strong><p>{message}</p></div>
<div className="studio-workflow-actions">
{canCreate ? (
<button
type="button"
onClick={() => { void createPreview(); }}
disabled={creating}
>
{creating
? "생성 중…"
: state === "NONE"
? "Public Preview 만들기"
: "Public Preview 다시 만들기"}
</button>
) : null}
{state === "CURRENT" ? (
<GuardedStudioLink href={`/studio/documents/${documentId}/publish`}>
</GuardedStudioLink>
) : null}
{!canCreate && state !== "CURRENT" ? (
<GuardedStudioLink href={`/studio/documents/${documentId}/validation`}>
</GuardedStudioLink>
) : null}
<GuardedStudioLink href={`/studio/documents/${documentId}/edit`}>
</GuardedStudioLink>
</div>
</section>
{feedback ? (
<p className="studio-workflow-feedback" role="status">{feedback}</p>
) : null}
{previewDetail ? (
<>
<dl className="studio-preview-metadata">
<div>
<dt>Preview </dt>
<dd>{previewDetail.preview.previewVersion}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(previewDetail.preview.createdAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(previewDetail.preview.expiresAt)}</dd>
</div>
</dl>
<div className="studio-preview-public-frame">
<PublicRecordRenderer
model={previewDetail.preview.renderModel}
embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
resolvePublishedLabel={() => undefined}
/>
</div>
</>
) : (
<section className="studio-workflow-empty-panel">
<h2> </h2>
<p> Public .</p>
</section>
)}
</div>
);
}
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { StudioDashboard as StudioDashboardData } from "../../../contracts/studio/contract.ts";
import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -27,9 +27,9 @@ function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {
<p className="studio-row-label">{kindLabel[item.kind]}</p>
<div>
<h3>
<Link to={`/studio/documents/${item.id}/edit`}>
<GuardedStudioLink href={`/studio/documents/${item.id}/edit`}>
{item.title || "제목 없는 작업본"}
</Link>
</GuardedStudioLink>
</h3>
<p>
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
@@ -59,7 +59,7 @@ function WorkSection({
<section className="studio-work-section">
<div className="studio-section-title">
<h2>{title}</h2>
<Link to={href}> </Link>
<GuardedStudioLink href={href}> </GuardedStudioLink>
</div>
<div className="studio-work-list">
{items.length ? (
@@ -105,9 +105,9 @@ export function StudioDashboard() {
<h1> </h1>
<p> · .</p>
</div>
<Link className="studio-primary-action" to="/studio/documents/new">
<GuardedStudioLink className="studio-primary-action" href="/studio/documents/new">
</Link>
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
@@ -160,7 +160,9 @@ export function StudioDashboard() {
<section className="studio-work-section">
<div className="studio-section-title">
<h2> </h2>
<Link to="/studio/publications"> </Link>
<GuardedStudioLink href="/studio/publications">
</GuardedStudioLink>
</div>
<div className="studio-work-list">
{dashboard.recentPublications.length ? (
@@ -1,5 +1,6 @@
import { useId, useState } from "react";
import { Link } from "react-router-dom";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const navigation = [
{
@@ -27,13 +28,13 @@ function StudioNavigation({
return (
<nav aria-label={label}>
{navigation.map((item) => (
<Link
<GuardedStudioLink
key={item.href}
to={item.href}
href={item.href}
aria-current={item.active(currentPath) ? "page" : undefined}
>
{item.label}
</Link>
</GuardedStudioLink>
))}
<a href="/"> </a>
</nav>
@@ -46,13 +47,13 @@ export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>)
return (
<header className="studio-header">
<div className="studio-header-inner">
<Link
<GuardedStudioLink
className="studio-wordmark"
to="/studio"
href="/studio"
aria-label="TechLog Studio"
>
TechLog <span>Studio</span>
</Link>
</GuardedStudioLink>
<div className="studio-desktop-navigation">
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
</div>
@@ -0,0 +1,95 @@
import { useEffect, useRef } from "react";
type UnsavedLeaveDialogProps = {
open: boolean;
saving: boolean;
message: string;
onStay(): void;
onDiscard(): void;
onSaveThenNavigate(): Promise<boolean>;
};
export function UnsavedLeaveDialog({
open,
saving,
message,
onStay,
onDiscard,
onSaveThenNavigate,
}: UnsavedLeaveDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const stayRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
stayRef.current?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
const closeAndStay = () => {
dialogRef.current?.close();
onStay();
};
const closeAndDiscard = () => {
dialogRef.current?.close();
onDiscard();
};
return (
<dialog
ref={dialogRef}
className="studio-unsaved-dialog"
aria-labelledby="studio-unsaved-title"
aria-describedby="studio-unsaved-description"
aria-hidden={open ? undefined : true}
onCancel={(event) => {
event.preventDefault();
if (!saving) closeAndStay();
}}
>
<div className="studio-dialog-body">
<p className="studio-eyebrow">UNSAVED CHANGES</p>
<h2 id="studio-unsaved-title"> </h2>
<p id="studio-unsaved-description">
Studio .
</p>
{message ? (
<p className="studio-dialog-error" role="alert">{message}</p>
) : null}
<div className="studio-dialog-actions">
<button
ref={stayRef}
type="button"
disabled={saving}
onClick={closeAndStay}
>
</button>
<button
type="button"
disabled={saving}
onClick={closeAndDiscard}
>
</button>
<button
type="button"
disabled={saving}
onClick={() => {
void onSaveThenNavigate().then((saved) => {
if (saved) dialogRef.current?.close();
});
}}
>
{saving ? "저장 중" : "저장 후 이동"}
</button>
</div>
</div>
</dialog>
);
}
@@ -0,0 +1,13 @@
import { useEffect } from "react";
export function useBeforeUnload(enabled: boolean): void {
useEffect(() => {
if (!enabled) return;
const protectDraft = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", protectDraft);
return () => window.removeEventListener("beforeunload", protectDraft);
}, [enabled]);
}
@@ -0,0 +1,93 @@
import type { ValidationReport as ValidationReportModel } from "../../../contracts/studio/contract.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const severityLabel = { ERROR: "오류", WARNING: "경고" } as const;
function pointerSegments(path: string): string[] {
return path
.split("/")
.slice(1)
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
.filter(Boolean);
}
export function validationFieldTarget(path: string): string {
const segments = pointerSegments(path).map((segment) =>
segment.replace(/[^a-zA-Z0-9_-]/g, "-")
);
return segments.length
? `studio-field-${segments.join("-")}`
: "studio-edit-panel";
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(date);
}
export function ValidationReport({
documentId,
report,
current,
}: {
documentId: string;
report: ValidationReportModel;
current: boolean;
}) {
const issues = report.issues.toSorted((left, right) => {
const rank = { ERROR: 0, WARNING: 1 } as const;
return rank[left.severity] - rank[right.severity];
});
return (
<section
className="studio-workflow-report"
aria-labelledby="studio-validation-report-title"
>
<div className="studio-workflow-section-heading">
<div>
<p className="studio-eyebrow">VALIDATION REPORT</p>
<h2 id="studio-validation-report-title"> </h2>
</div>
<span
className={`studio-workflow-status studio-workflow-status--${report.status.toLowerCase()}`}
>
{report.status}
</span>
</div>
<dl className="studio-workflow-metadata">
<div><dt> </dt><dd>{report.validatedVersion}</dd></div>
<div><dt></dt><dd>{current ? "현재" : "이전 결과"}</dd></div>
<div><dt> </dt><dd>{formatDateTime(report.validatedAt)}</dd></div>
<div><dt> </dt><dd>{formatDateTime(report.validUntil)}</dd></div>
</dl>
{issues.length ? (
<ol className="studio-workflow-issue-list" aria-label="검증 항목">
{issues.map((issue, index) => (
<li key={`${issue.severity}-${issue.code}-${issue.path}-${index}`}>
<GuardedStudioLink
href={`/studio/documents/${documentId}/edit#${validationFieldTarget(issue.path)}`}
>
<span className="studio-workflow-issue-severity">
{severityLabel[issue.severity]}
</span>
<span className="studio-workflow-issue-body">
<strong>{issue.message}</strong>
<small>{issue.code} · {issue.path || "/"}</small>
</span>
<span aria-hidden="true"> </span>
</GuardedStudioLink>
</li>
))}
</ol>
) : (
<p className="studio-workflow-empty-issues"> .</p>
)}
</section>
);
}
@@ -0,0 +1,225 @@
import { useEffect, useRef, useState } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type {
ValidationReport as ValidationReportModel,
WorkingCopyDetail,
} from "../../../contracts/studio/contract.ts";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { ValidationReport } from "./validation-report.tsx";
function isAbortError(error: unknown): boolean {
return typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError";
}
function isCurrent(
detail: WorkingCopyDetail,
report: ValidationReportModel | null,
now: Date,
): boolean {
if (!report) return false;
return deriveValidationState({
...detail,
currentValidation: report,
now,
}).freshness === "CURRENT";
}
export function ValidationScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const [retryKey, setRetryKey] = useState(0);
const [detail, setDetail] = useState<WorkingCopyDetail | null>(null);
const [problem, setProblem] = useState<StudioGatewayError | Error | null>(null);
const [loading, setLoading] = useState(true);
const [validating, setValidating] = useState(false);
const [completion, setCompletion] = useState("");
const sequence = useRef(0);
useEffect(() => {
const request = new AbortController();
void studio.gateway.getDocument(documentId, { signal: request.signal }).then(
(next) => {
if (request.signal.aborted) return;
setDetail(next);
setLoading(false);
},
(error: unknown) => {
if (request.signal.aborted || isAbortError(error)) return;
setProblem(
error instanceof Error
? error
: new Error("검증 화면을 불러오지 못했습니다."),
);
setLoading(false);
},
);
return () => request.abort();
}, [documentId, retryKey, studio.gateway]);
const retry = () => {
setLoading(true);
setProblem(null);
setRetryKey((value) => value + 1);
};
const validate = async () => {
if (!detail || validating) return;
setValidating(true);
setCompletion("");
try {
const report = await studio.gateway.validateDocument(
documentId,
{ expectedVersion: detail.document.version },
{
idempotencyKey: createLocalId(
`studio-validation-${++sequence.current}`,
),
},
);
setDetail((current) => current
? { ...current, currentValidation: report }
: current);
setCompletion("검증이 완료되었습니다.");
studio.setRequestAnnouncement("저장된 문서 검증이 완료되었습니다.");
} catch (error) {
if (isAbortError(error)) return;
setCompletion(
isStudioGatewayError(error)
? error.problem.detail
: "검증하지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setValidating(false);
}
};
if (loading) {
return (
<p className="studio-workflow-loading" role="status">
.
</p>
);
}
if (
problem &&
isStudioGatewayError(problem) &&
problem.code === "DOCUMENT_NOT_FOUND"
) {
return (
<section className="studio-route-state">
<p className="studio-eyebrow">404</p>
<h1> </h1>
<p> Studio .</p>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
if (problem || !detail) {
return (
<section className="studio-route-state" role="alert">
<p className="studio-eyebrow">VALIDATION ERROR</p>
<h1> </h1>
<p>
{problem && isStudioGatewayError(problem)
? problem.problem.detail
: problem?.message}
</p>
<button type="button" onClick={retry}> </button>
</section>
);
}
const report = detail.currentValidation;
const current = isCurrent(detail, report, studio.now());
const ready = current && report?.status !== "INVALID";
const gateTitle = !report
? "아직 검증하지 않았습니다"
: !current
? "저장 후 다시 검증해야 합니다"
: report.status === "INVALID"
? "검증 오류를 수정해야 합니다"
: report.status === "WARNINGS"
? "경고를 확인하고 Preview를 만들 수 있습니다"
: "현재 저장 버전의 검증을 통과했습니다";
return (
<div className="studio-workflow-screen">
<header className="studio-workflow-heading">
<div>
<p className="studio-eyebrow">
DOCUMENT VALIDATION · VERSION {detail.document.version}
</p>
<h1> </h1>
<p>{detail.document.title}</p>
</div>
<GuardedStudioLink
className="studio-workflow-quiet-link"
href={`/studio/documents/${documentId}/edit`}
>
</GuardedStudioLink>
</header>
<section
className={`studio-workflow-gate ${ready ? "studio-workflow-gate--ready" : "studio-workflow-gate--blocked"}`}
aria-labelledby="studio-validation-gate-title"
>
<div>
<p className="studio-eyebrow">WORKFLOW GATE</p>
<h2 id="studio-validation-gate-title">{gateTitle}</h2>
<p>
{" "}
{detail.document.version} .
</p>
</div>
<div className="studio-workflow-actions">
<button
type="button"
onClick={() => { void validate(); }}
disabled={validating}
>
{validating ? "검증 중…" : report ? "다시 검증" : "검증하기"}
</button>
{ready ? (
<GuardedStudioLink href={`/studio/documents/${documentId}/preview`}>
Public Preview
</GuardedStudioLink>
) : null}
{current && report?.status === "INVALID" ? (
<GuardedStudioLink href={`/studio/documents/${documentId}/edit`}>
</GuardedStudioLink>
) : null}
</div>
</section>
{completion ? (
<p className="studio-workflow-feedback" role="status">{completion}</p>
) : null}
{report ? (
<ValidationReport
documentId={documentId}
report={report}
current={current}
/>
) : (
<section className="studio-workflow-empty-panel">
<h2> </h2>
<p>
.
</p>
</section>
)}
</div>
);
}
@@ -0,0 +1,7 @@
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { PublicPreviewScreen } from "../components/public-preview-screen.tsx";
export function DocumentPreviewPage() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PREVIEW">();
return <PublicPreviewScreen documentId={String(params.id)} />;
}
@@ -0,0 +1,7 @@
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ValidationScreen } from "../components/validation-screen.tsx";
export function DocumentValidationPage() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_VALIDATION">();
return <ValidationScreen documentId={String(params.id)} />;
}
@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { GuardedStudioLink } from "../components/guarded-studio-link.tsx";
export function StudioNotFoundPage() {
return (
@@ -6,7 +6,9 @@ export function StudioNotFoundPage() {
<p className="studio-eyebrow">404</p>
<h1>Studio </h1>
<p> .</p>
<Link to="/studio/documents"> </Link>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
@@ -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>
);
}
@@ -17,10 +17,11 @@ export type StudioEditorState = Readonly<{
export type StudioContextValue = Readonly<{
gateway: StudioGateway;
now(): Date;
editor: StudioEditorState | null;
requestAnnouncement: string;
setRequestAnnouncement(message: string): void;
navigateInternal(href: string): void;
navigateInternal(href: string, trigger?: HTMLElement | null): void;
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
updateEditorDraft(draft: WorkingCopyInput): void;
setEditorStatus(status: StudioEditorStatus): void;