feat: complete TechLog Studio publication flow
This commit is contained in:
@@ -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 = {
|
||||
|
||||
+176
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user