feat: port TechLog Studio shell and indexes
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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";
|
||||
|
||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||
|
||||
const kindLabel = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Question",
|
||||
} as const;
|
||||
const actionLabel = {
|
||||
CONTINUE_EDITING: "작성 계속",
|
||||
VALIDATE: "검증하기",
|
||||
FIX_VALIDATION: "오류 수정",
|
||||
CREATE_PREVIEW: "미리보기 만들기",
|
||||
PUBLISH: "게시하기",
|
||||
NONE: "게시 완료",
|
||||
} as const;
|
||||
|
||||
function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {
|
||||
return (
|
||||
<article className="studio-work-row">
|
||||
<p className="studio-row-label">{kindLabel[item.kind]}</p>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={`/studio/documents/${item.id}/edit`}>
|
||||
{item.title || "제목 없는 작업본"}
|
||||
</Link>
|
||||
</h3>
|
||||
<p>
|
||||
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
|
||||
</p>
|
||||
</div>
|
||||
<time dateTime={item.updatedAt}>
|
||||
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
})}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkSection({
|
||||
title,
|
||||
items,
|
||||
href,
|
||||
empty,
|
||||
}: Readonly<{
|
||||
title: string;
|
||||
items: DocumentSummary[];
|
||||
href: string;
|
||||
empty: string;
|
||||
}>) {
|
||||
return (
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>{title}</h2>
|
||||
<Link to={href}>전체 보기</Link>
|
||||
</div>
|
||||
<div className="studio-work-list">
|
||||
{items.length ? (
|
||||
items.map((item) => <WorkRow key={item.id} item={item} />)
|
||||
) : (
|
||||
<p className="studio-empty-inline">{empty}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function StudioDashboard() {
|
||||
const { gateway } = useStudio();
|
||||
const [dashboard, setDashboard] = useState<StudioDashboardData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void gateway.getDashboard({ signal: controller.signal }).then(
|
||||
(value) => setDashboard(value),
|
||||
(reason: unknown) => {
|
||||
if (!isAbortError(reason)) setError("작업 흐름을 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [gateway]);
|
||||
|
||||
const validationItems =
|
||||
dashboard?.continueWriting.filter((item) =>
|
||||
["VALIDATE", "FIX_VALIDATION", "CREATE_PREVIEW"].includes(item.nextAction),
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-dashboard-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">WORKSPACE</p>
|
||||
<h1>작업 흐름</h1>
|
||||
<p>작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.</p>
|
||||
</div>
|
||||
<Link className="studio-primary-action" to="/studio/documents/new">
|
||||
새 문서
|
||||
</Link>
|
||||
</header>
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!dashboard && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
Studio 요약을 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
{dashboard ? (
|
||||
<>
|
||||
<section className="studio-summary-grid" aria-label="Studio 요약">
|
||||
<div>
|
||||
<span>전체 작업본</span>
|
||||
<strong>{dashboard.totals.documents}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>검증할 기록</span>
|
||||
<strong>{validationItems.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>게시 준비</span>
|
||||
<strong>{dashboard.totals.readyToPublish}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>게시 기록</span>
|
||||
<strong>{dashboard.totals.publications}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<WorkSection
|
||||
title="이어서 작성"
|
||||
items={dashboard.continueWriting}
|
||||
href="/studio/documents"
|
||||
empty="이어서 작성할 문서가 없습니다."
|
||||
/>
|
||||
<WorkSection
|
||||
title="검증과 미리보기"
|
||||
items={validationItems}
|
||||
href="/studio/documents"
|
||||
empty="현재 검증할 문서가 없습니다."
|
||||
/>
|
||||
<WorkSection
|
||||
title="게시 준비"
|
||||
items={dashboard.readyToPublish}
|
||||
href="/studio/documents"
|
||||
empty="게시 준비가 끝난 문서가 없습니다."
|
||||
/>
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>최근 게시</h2>
|
||||
<Link to="/studio/publications">게시 기록 보기</Link>
|
||||
</div>
|
||||
<div className="studio-work-list">
|
||||
{dashboard.recentPublications.length ? (
|
||||
dashboard.recentPublications.map((item) => (
|
||||
<article
|
||||
className="studio-work-row"
|
||||
key={item.event.publicationEventId}
|
||||
>
|
||||
<p className="studio-row-label">
|
||||
{item.event.type === "UNPUBLISHED"
|
||||
? "게시 취소"
|
||||
: item.event.type === "REPUBLISHED"
|
||||
? "재게시"
|
||||
: "게시"}
|
||||
</p>
|
||||
<div>
|
||||
<h3>{item.document.title}</h3>
|
||||
<p>{item.document.project?.label ?? "프로젝트 미지정"}</p>
|
||||
</div>
|
||||
<time dateTime={item.event.occurredAt}>
|
||||
{new Date(item.event.occurredAt).toLocaleDateString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
})}
|
||||
</time>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className="studio-empty-inline">아직 게시 기록이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user