feat: port TechLog Studio validation workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user