feat: complete TechLog Studio publication flow

This commit is contained in:
DongHyeonka
2026-08-16 00:35:11 +09:00
parent 9c6906fc6f
commit c5c8b9423c
60 changed files with 2028 additions and 2948 deletions
@@ -0,0 +1,14 @@
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import type { StudioEditorStatus } from "../use-studio.ts";
export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
};
@@ -7,7 +7,8 @@ import type {
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
@@ -1,11 +1,7 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import type { StudioEditorStatus } from "../use-studio.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
@@ -15,15 +11,6 @@ import { ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
};
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
@@ -1,4 +1,4 @@
import type { DocumentEditorController } from "./document-editor.tsx";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const labels = {
@@ -0,0 +1,176 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed event or retry intentionally enters a fresh loading state. */
import { useEffect, useState } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts";
import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { useStudio } from "../use-studio.ts";
const eventLabels = {
PUBLISHED: "게시",
REPUBLISHED: "재게시",
UNPUBLISHED: "게시 취소",
} as const;
function resolveEvidenceAsset(key: string): EvidenceAsset {
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의 페이징 경계 확대",
};
}
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "long",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
export function PublicationEventPreviewScreen({
publicationEventId,
classes: styles = defaultPublicationFlowClasses,
}: {
publicationEventId: string;
classes?: PublicationFlowClasses;
}) {
const { gateway } = useStudio();
const [retry, setRetry] = useState(0);
const [state, setState] = useState<
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; snapshot: PublicationSnapshot }
>({ status: "LOADING" });
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
void gateway
.getPublicationSnapshot(publicationEventId, {
signal: controller.signal,
})
.then(
(snapshot) => {
if (current) setState({ status: "READY", snapshot });
},
(error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("Snapshot을 불러오지 못했습니다."),
});
},
);
return () => {
current = false;
controller.abort();
};
}, [gateway, publicationEventId, retry]);
if (state.status === "LOADING") {
return (
<p className={styles.loading} role="status">
Snapshot을 .
</p>
);
}
if (state.status === "ERROR") {
const missing =
isStudioGatewayError(state.problem) &&
(state.problem.code === "PUBLICATION_EVENT_NOT_FOUND" ||
state.problem.code === "PUBLICATION_SNAPSHOT_NOT_FOUND");
if (missing) {
return (
<section className={styles.routeState}>
<p className={styles.eyebrow}>NOT FOUND</p>
<h1> </h1>
<p>
Studio Snapshot을
.
</p>
<GuardedStudioLink href="/studio/publications">
</GuardedStudioLink>
</section>
);
}
return (
<section className={styles.routeState} role="alert">
<p className={styles.eyebrow}>SNAPSHOT ERROR</p>
<h1> Snapshot을 </h1>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</section>
);
}
const { event, renderModel } = state.snapshot;
return (
<div className={styles.snapshotPage}>
<section
className={styles.snapshotToolbar}
aria-label="게시 Snapshot 정보"
>
<div>
<p className={styles.eyebrow}>IMMUTABLE SNAPSHOT</p>
<p className={styles.snapshotTitle}> </p>
</div>
<dl>
<div>
<dt></dt>
<dd>{eventLabels[event.type]}</dd>
</div>
<div>
<dt> </dt>
<dd>v{event.publishedVersion}</dd>
</div>
<div>
<dt> </dt>
<dd>
<time dateTime={event.occurredAt}>
{dateTime(event.occurredAt)}
</time>
</dd>
</div>
</dl>
<GuardedStudioLink href="/studio/publications">
</GuardedStudioLink>
</section>
<div className={styles.snapshotDocument}>
<PublicRecordRenderer
model={renderModel}
embedded
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={() => undefined}
/>
</div>
</div>
);
}
@@ -0,0 +1,289 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed history query intentionally enters a fresh loading state. */
import { useEffect, useRef, useState, type FormEvent } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type { ListPublicationsQuery } from "../../../application/ports/studio-gateway.ts";
import type {
PublicationListItem,
PublicationPage,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { UnpublishDialog } from "./unpublish-dialog.tsx";
import { useStudio } from "../use-studio.ts";
const eventLabels = {
PUBLISHED: "게시",
REPUBLISHED: "재게시",
UNPUBLISHED: "게시 취소",
} as const;
const kindLabels = {
CASE: "Case",
REFERENCE: "Reference",
QUESTION: "Question",
} as const;
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
function snapshotHref(item: PublicationListItem) {
if (
item.availableActions.includes("VIEW_SOURCE_SNAPSHOT") &&
item.event.sourcePublishedEventId
) {
return `/studio/publications/${item.event.sourcePublishedEventId}/preview`;
}
if (item.availableActions.includes("VIEW_SNAPSHOT")) {
return `/studio/publications/${item.event.publicationEventId}/preview`;
}
return null;
}
export function PublicationList({
classes: styles = defaultPublicationFlowClasses,
}: { classes?: PublicationFlowClasses } = {}) {
const studio = useStudio();
const [query, setQuery] = useState<ListPublicationsQuery>({ limit: 100 });
const [queryDraft, setQueryDraft] = useState("");
const [typeDraft, setTypeDraft] =
useState<ListPublicationsQuery["type"]>();
const [reload, setReload] = useState(0);
const [state, setState] = useState<
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; page: PublicationPage }
>({ status: "LOADING" });
const [selected, setSelected] = useState<PublicationListItem | null>(null);
const [pending, setPending] = useState(false);
const [dialogError, setDialogError] = useState("");
const [announcement, setAnnouncement] = useState("");
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
void studio.gateway.listPublications(query, {
signal: controller.signal,
}).then(
(page) => {
if (current) setState({ status: "READY", page });
},
(error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("게시 기록을 불러오지 못했습니다."),
});
},
);
return () => {
current = false;
controller.abort();
};
}, [query, reload, studio.gateway]);
const applyFilter = (event: FormEvent) => {
event.preventDefault();
setQuery({
...(queryDraft.trim() ? { q: queryDraft.trim().slice(0, 100) } : {}),
...(typeDraft ? { type: typeDraft } : {}),
limit: 100,
});
};
const dismiss = () => {
setSelected(null);
setDialogError("");
queueMicrotask(() => triggerRef.current?.focus());
};
const confirmUnpublish = async () => {
if (
!selected ||
pending ||
!selected.availableActions.includes("UNPUBLISH")
) {
return;
}
setPending(true);
setDialogError("");
try {
const result = await studio.gateway.unpublishPublication(
selected.publication.publicationId,
{
expectedPublicationRevision:
selected.publication.publicationRevision,
},
{ idempotencyKey: createLocalId("studio-unpublish") },
);
setSelected(null);
setAnnouncement("게시를 취소했습니다.");
studio.setRequestAnnouncement("게시를 취소했습니다.");
setReload((value) => value + 1);
} catch (error) {
setDialogError(
isStudioGatewayError(error)
? error.problem.detail
: "게시를 취소하지 못했습니다.",
);
} finally {
setPending(false);
}
};
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION EVENTS</p>
<h1> </h1>
<p>
·· Snapshot을 .
</p>
</header>
<form className={styles.filters} onSubmit={applyFilter}>
<label>
<span></span>
<input
value={queryDraft}
maxLength={100}
onChange={(event) => setQueryDraft(event.currentTarget.value)}
placeholder="제목 또는 요약"
/>
</label>
<label>
<span></span>
<select
value={typeDraft ?? ""}
onChange={(event) =>
setTypeDraft(
(event.currentTarget.value || undefined) as ListPublicationsQuery["type"],
)
}
>
<option value=""></option>
<option value="PUBLISHED"></option>
<option value="REPUBLISHED"></option>
<option value="UNPUBLISHED"> </option>
</select>
</label>
<button type="submit"></button>
</form>
{announcement ? (
<p className={styles.success} role="status">
{announcement}
</p>
) : null}
{state.status === "LOADING" ? (
<p className={styles.loading} role="status">
.
</p>
) : null}
{state.status === "ERROR" ? (
<section className={styles.errorState} role="alert">
<h2> </h2>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setReload((value) => value + 1)}>
</button>
</section>
) : null}
{state.status === "READY" && state.page.items.length === 0 ? (
<section className={styles.emptyState}>
<h2> </h2>
<p> .</p>
</section>
) : null}
{state.status === "READY" && state.page.items.length > 0 ? (
<ol className={styles.historyList}>
{state.page.items.map((item) => {
const href = snapshotHref(item);
const source = item.availableActions.includes(
"VIEW_SOURCE_SNAPSHOT",
);
const canUnpublish = item.availableActions.includes("UNPUBLISH");
return (
<li key={item.event.publicationEventId}>
<article className={styles.historyRow}>
<p className={styles.eventType}>
{eventLabels[item.event.type]}
</p>
<div className={styles.eventMain}>
<p className={styles.kind}>
{kindLabels[item.document.kind]} · v
{item.event.publishedVersion}
</p>
<h2>{item.document.title || "제목 없는 작업본"}</h2>
<p>
: {eventLabels[item.event.type]} · :{" "}
{item.publication.status === "PUBLISHED"
? "게시 중"
: "게시 취소"}
</p>
</div>
<time dateTime={item.event.occurredAt}>
{dateTime(item.event.occurredAt)}
</time>
<div className={styles.rowActions}>
{href ? (
<GuardedStudioLink href={href}>
{source ? "게시 취소 전 Snapshot 보기" : "Snapshot 보기"}
</GuardedStudioLink>
) : null}
{canUnpublish ? (
<button
type="button"
aria-label={`${item.document.title || "제목 없는 작업본"} 게시 취소`}
onClick={(event) => {
triggerRef.current = event.currentTarget;
setDialogError("");
setSelected(item);
}}
>
</button>
) : null}
</div>
</article>
</li>
);
})}
</ol>
) : null}
<UnpublishDialog
item={selected}
pending={pending}
error={dialogError}
onDismiss={dismiss}
onConfirm={() => {
void confirmUnpublish();
}}
classes={styles}
/>
</div>
);
}
@@ -0,0 +1,403 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed document or retry intentionally enters a fresh loading state. */
import { useEffect, useMemo, 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 { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { useStudio } from "../use-studio.ts";
import { WarningAcknowledgements } from "./warning-acknowledgements.tsx";
type LoadState =
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; detail: WorkingCopyDetail; preview: PreviewDetail | null };
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
function notFound(problem: StudioGatewayError | Error) {
return isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND";
}
export function PublishScreen({
documentId,
classes: styles = defaultPublicationFlowClasses,
}: {
documentId: string;
classes?: PublicationFlowClasses;
}) {
const studio = useStudio();
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<LoadState>({ status: "LOADING" });
const [acknowledged, setAcknowledged] = useState<Set<string>>(
() => new Set(),
);
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
setAcknowledged(new Set());
setActionError("");
setSuccess("");
void (async () => {
const detail = await studio.gateway.getDocument(documentId, {
signal: controller.signal,
});
const sameVersion =
detail.currentPublication?.status === "PUBLISHED" &&
detail.currentPublication.publishedVersion === detail.document.version;
let preview: PreviewDetail | null = null;
if (!sameVersion && detail.latestPreview) {
try {
preview = await studio.gateway.getCurrentPreview(documentId, {
signal: controller.signal,
});
} catch (error) {
if (
!(
isStudioGatewayError(error) &&
error.code === "PREVIEW_NOT_FOUND"
)
) {
throw error;
}
}
}
if (current) setState({ status: "READY", detail, preview });
})().catch((error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("게시 정보를 불러오지 못했습니다."),
});
});
return () => {
current = false;
controller.abort();
};
}, [attempt, documentId, studio.gateway]);
const ready = state.status === "READY" ? state : null;
const validation = ready?.detail.currentValidation ?? null;
const warnings = useMemo(
() =>
validation?.issues.filter((issue) => issue.severity === "WARNING") ?? [],
[validation],
);
if (state.status === "LOADING") {
return (
<p className={styles.loading} role="status">
.
</p>
);
}
if (state.status === "ERROR" && notFound(state.problem)) {
return (
<section className={styles.routeState}>
<p className={styles.eyebrow}>NOT FOUND</p>
<h1> </h1>
<p> Studio .</p>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
if (state.status === "ERROR") {
return (
<section className={styles.routeState} role="alert">
<p className={styles.eyebrow}>PUBLISH ERROR</p>
<h1> </h1>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
</button>
</section>
);
}
const { detail, preview } = state;
const { document, currentPublication } = detail;
const sameVersion =
currentPublication?.status === "PUBLISHED" &&
currentPublication.publishedVersion === document.version;
const validationMatches =
validation !== null &&
deriveValidationState({ ...detail, now: studio.now() }).freshness ===
"CURRENT";
const validationAllowsPreview =
validationMatches && validation?.status !== "INVALID";
const previewIsCurrent =
preview?.state === "CURRENT" &&
preview.preview.previewVersion === document.version &&
preview.currentValidationId === validation?.validationId;
const warningsDone = warnings.every((warning) =>
acknowledged.has(warning.code),
);
const canPublish =
validationAllowsPreview && previewIsCurrent && warningsDone && !pending;
const actionLabel = currentPublication ? "다시 게시" : "게시";
const toggleWarning = (code: string, checked: boolean) => {
setAcknowledged((current) => {
const next = new Set(current);
if (checked) next.add(code);
else next.delete(code);
return next;
});
};
const publish = async () => {
if (!canPublish || !validation || !preview) return;
setPending(true);
setActionError("");
setSuccess("");
try {
const result = await studio.gateway.publishDocument(
document.id,
{
expectedVersion: document.version,
validationId: validation.validationId,
previewId: preview.preview.previewId,
acknowledgedWarningCodes: [...acknowledged].sort(),
},
{ idempotencyKey: createLocalId("studio-publish") },
);
setSuccess("게시했습니다.");
studio.setRequestAnnouncement("게시했습니다.");
studio.clearEditor();
studio.navigateInternal(
`/studio/publications/${result.event.publicationEventId}/preview`,
);
} catch (error) {
setActionError(
isStudioGatewayError(error)
? error.problem.detail
: "게시하지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setPending(false);
}
};
if (sameVersion && currentPublication) {
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION</p>
<h1> </h1>
<p>{document.title}</p>
</header>
<section
className={styles.gateReady}
aria-labelledby="same-version-title"
>
<h2 id="same-version-title"> </h2>
<p>
v{document.version} .
.
</p>
<GuardedStudioLink
href={`/studio/publications/${currentPublication.latestEventId}/preview`}
>
Snapshot
</GuardedStudioLink>
</section>
</div>
);
}
let blocked: {
title: string;
detail: string;
href: string;
label: string;
} | null = null;
if (!validation) {
blocked = {
title: "저장 버전 검증이 필요합니다",
detail: "게시 전에 현재 저장 버전을 검증해 주세요.",
href: `/studio/documents/${document.id}/validation`,
label: "검증하기",
};
} else if (!validationMatches) {
blocked = {
title: "검증 결과가 현재 버전과 다릅니다",
detail: "현재 저장 버전으로 다시 검증해야 합니다.",
href: `/studio/documents/${document.id}/validation`,
label: "다시 검증",
};
} else if (validation.status === "INVALID") {
blocked = {
title: "검증 오류를 먼저 수정해야 합니다",
detail: `${validation.issues.filter((issue) => issue.severity === "ERROR").length}개의 오류가 게시를 막고 있습니다.`,
href: `/studio/documents/${document.id}/validation`,
label: "검증 오류 보기",
};
} else if (!preview) {
blocked = {
title: "Public Preview가 필요합니다",
detail: "검증된 저장 버전의 공개 레이아웃을 먼저 확인해 주세요.",
href: `/studio/documents/${document.id}/preview`,
label: "Public Preview 만들기",
};
} else if (!previewIsCurrent) {
blocked = {
title:
preview.state === "EXPIRED"
? "Public Preview가 만료되었습니다"
: "Public Preview가 오래되었습니다",
detail: "현재 검증과 저장 버전으로 Preview를 다시 만들어야 합니다.",
href: `/studio/documents/${document.id}/preview`,
label: "Public Preview 다시 만들기",
};
}
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION</p>
<h1> </h1>
<p>{document.title || "제목 없는 작업본"}</p>
</header>
<section className={styles.summary} aria-label="게시 준비 요약">
<dl>
<div>
<dt> </dt>
<dd>v{document.version}</dd>
</div>
<div>
<dt></dt>
<dd>
{validation
? `${validation.status} · v${validation.validatedVersion}`
: "실행 전"}
</dd>
</div>
<div>
<dt>Public Preview</dt>
<dd>
{preview
? `${preview.state} · v${preview.preview.previewVersion}`
: "없음"}
</dd>
</div>
<div>
<dt> </dt>
<dd>
{currentPublication
? `${currentPublication.status} · v${currentPublication.publishedVersion}`
: "게시 전"}
</dd>
</div>
</dl>
</section>
<section
className={styles.changeSummary}
aria-labelledby="change-summary-title"
>
<p className={styles.eyebrow}>CHANGE SUMMARY</p>
<h2 id="change-summary-title"> </h2>
<p>
{currentPublication
? `게시 버전 v${currentPublication.publishedVersion}에서 저장 버전 v${document.version}으로 반영합니다.`
: `저장 버전 v${document.version}을 처음 게시합니다.`}
</p>
{preview ? (
<p>
Preview :{" "}
<time dateTime={preview.preview.expiresAt}>
{dateTime(preview.preview.expiresAt)}
</time>
</p>
) : null}
</section>
{blocked ? (
<section
className={styles.blocked}
aria-labelledby="publish-blocked-title"
>
<h2 id="publish-blocked-title">{blocked.title}</h2>
<p>{blocked.detail}</p>
<GuardedStudioLink href={blocked.href}>
{blocked.label}
</GuardedStudioLink>
</section>
) : (
<section
className={styles.publishAction}
aria-labelledby="publish-action-title"
>
<h2 id="publish-action-title">{actionLabel}</h2>
<WarningAcknowledgements
warnings={warnings}
acknowledged={acknowledged}
onToggle={toggleWarning}
classes={styles}
/>
{actionError ? (
<p className={styles.error} role="alert">
{actionError}
</p>
) : null}
{success ? (
<p className={styles.success} role="status">
{success}
</p>
) : null}
<div className={styles.actions}>
<GuardedStudioLink
href={`/studio/documents/${document.id}/preview`}
>
Preview로
</GuardedStudioLink>
<button
type="button"
disabled={!canPublish}
onClick={() => {
void publish();
}}
>
{pending ? "게시 중…" : actionLabel}
</button>
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
import type { PublicationListItem } from "../../../contracts/studio/contract.ts";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
export function UnpublishDialog({
item,
pending,
error,
onDismiss,
onConfirm,
classes: styles = defaultPublicationFlowClasses,
}: {
item: PublicationListItem | null;
pending: boolean;
error: string;
onDismiss(): void;
onConfirm(): void;
classes?: PublicationFlowClasses;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (item && !dialog.open) {
dialog.showModal();
cancelRef.current?.focus();
} else if (!item && dialog.open) {
dialog.close();
}
}, [item]);
useEffect(
() => () => {
if (dialogRef.current?.open) dialogRef.current.close();
},
[],
);
const trapFocus = (event: KeyboardEvent<HTMLDialogElement>) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"button:not([disabled])",
),
);
if (!controls.length) return;
const first = controls[0];
const last = controls.at(-1)!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
return (
<dialog
ref={dialogRef}
className={styles.dialog}
aria-labelledby={titleId}
aria-describedby={descriptionId}
onCancel={(event) => {
event.preventDefault();
if (!pending) onDismiss();
}}
onKeyDown={trapFocus}
>
<div className={styles.dialogBody}>
<p className={styles.eyebrow}>UNPUBLISH</p>
<h2 id={titleId}> ?</h2>
<p id={descriptionId}>
{item?.document.title ?? "선택한 기록"} Studio
.
</p>
<div className={styles.preservedNote}>
<strong> Snapshot은 .</strong>
<p>
Mock에서는 Public .
</p>
</div>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={styles.dialogActions}>
<button
ref={cancelRef}
type="button"
disabled={pending}
onClick={onDismiss}
>
</button>
<button type="button" disabled={pending} onClick={onConfirm}>
{pending ? "취소 처리 중…" : "게시 취소 확인"}
</button>
</div>
</div>
</dialog>
);
}
@@ -39,7 +39,7 @@ export function ValidationReport({
report: ValidationReportModel;
current: boolean;
}) {
const issues = report.issues.toSorted((left, right) => {
const issues = [...report.issues].sort((left, right) => {
const rank = { ERROR: 0, WARNING: 1 } as const;
return rank[left.severity] - rank[right.severity];
});
@@ -0,0 +1,64 @@
import { useId } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
type ValidationIssue = components["schemas"]["ValidationIssue"];
export function WarningAcknowledgements({
warnings,
acknowledged,
onToggle,
classes: styles = defaultPublicationFlowClasses,
}: {
warnings: ValidationIssue[];
acknowledged: ReadonlySet<string>;
onToggle(code: string, checked: boolean): void;
classes?: PublicationFlowClasses;
}) {
const groupId = useId();
if (warnings.length === 0) {
return <p className={styles.noWarnings}> .</p>;
}
return (
<fieldset
className={styles.warningGroup}
aria-describedby={`${groupId}-description`}
>
<legend> </legend>
<p id={`${groupId}-description`}>
.
</p>
<div className={styles.warningList}>
{warnings.map((warning, index) => {
const inputId = `${groupId}-warning-${index}`;
return (
<label
className={styles.warningItem}
htmlFor={inputId}
key={`${warning.code}:${warning.path}`}
>
<input
id={inputId}
type="checkbox"
checked={acknowledged.has(warning.code)}
onChange={(event) =>
onToggle(warning.code, event.currentTarget.checked)
}
/>
<span>
<strong>{warning.code}</strong>
<small>{warning.message}</small>
</span>
</label>
);
})}
</div>
</fieldset>
);
}