feat: port TechLog Studio shell and indexes

This commit is contained in:
DongHyeonka
2026-08-15 23:29:32 +09:00
parent 2b6fa42620
commit 887f5e6eb1
15 changed files with 1187 additions and 0 deletions
@@ -0,0 +1,226 @@
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";
const kindLabel = {
CASE: "Case",
REFERENCE: "Reference",
QUESTION: "Question",
} as const;
const publicationLabel = {
NEVER_PUBLISHED: "게시 전",
PUBLISHED: "게시 중",
UNPUBLISHED: "게시 취소",
} as const;
const nextLabel = {
CONTINUE_EDITING: "작성 계속",
VALIDATE: "검증하기",
FIX_VALIDATION: "오류 수정",
CREATE_PREVIEW: "미리보기",
PUBLISH: "게시하기",
NONE: "완료",
} as const;
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function DocumentList() {
const { gateway } = useStudio();
const [searchDraft, setSearchDraft] = useState("");
const [q, setQ] = useState("");
const [kind, setKind] = useState<ListDocumentsQuery["kind"]>();
const [status, setStatus] =
useState<ListDocumentsQuery["publicationStatus"]>();
const [cursor, setCursor] = useState<string>();
const [page, setPage] = useState<DocumentPage | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [retryGeneration, setRetryGeneration] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
void gateway
.listDocuments(
{
...(q ? { q } : {}),
...(kind ? { kind } : {}),
...(status ? { publicationStatus: status } : {}),
...(cursor ? { cursor } : {}),
limit: 20,
},
{ signal: controller.signal },
)
.then(
(value) => {
setPage(value);
setLoading(false);
},
(reason: unknown) => {
if (isAbortError(reason)) return;
setError("작업본을 불러오지 못했습니다.");
setLoading(false);
},
);
return () => controller.abort();
}, [cursor, gateway, kind, q, retryGeneration, status]);
const changeFilter = (next: {
kind?: ListDocumentsQuery["kind"];
status?: ListDocumentsQuery["publicationStatus"];
}) => {
if ("kind" in next) setKind(next.kind);
if ("status" in next) setStatus(next.status);
setCursor(undefined);
};
const submitSearch = (event: FormEvent) => {
event.preventDefault();
setQ(searchDraft.trim());
setCursor(undefined);
};
return (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">WORKING COPIES</p>
<h1></h1>
<p>
Case, Reference, Question을 .
</p>
</div>
<Link className="studio-primary-action" to="/studio/documents/new">
</Link>
</header>
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
<form role="search" onSubmit={submitSearch}>
<label htmlFor="studio-search"></label>
<div>
<input
id="studio-search"
type="search"
value={searchDraft}
placeholder="제목, 요약, slug"
onChange={(event) => setSearchDraft(event.target.value)}
/>
<button type="submit"></button>
</div>
</form>
<label>
<select
value={kind ?? ""}
onChange={(event) =>
changeFilter({
kind: (event.target.value || undefined) as ListDocumentsQuery["kind"],
})
}
>
<option value=""></option>
<option value="CASE">Case</option>
<option value="REFERENCE">Reference</option>
<option value="QUESTION">Question</option>
</select>
</label>
<label>
<select
value={status ?? ""}
onChange={(event) =>
changeFilter({
status: (event.target.value || undefined) as ListDocumentsQuery["publicationStatus"],
})
}
>
<option value=""></option>
<option value="NEVER_PUBLISHED"> </option>
<option value="PUBLISHED"> </option>
<option value="UNPUBLISHED"> </option>
</select>
</label>
</section>
{loading ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{error ? (
<>
<p className="studio-screen-error" role="alert">
{error}
</p>
<button
className="studio-secondary-button"
type="button"
onClick={() => setRetryGeneration((current) => current + 1)}
>
</button>
</>
) : null}
{page && !loading && !error ? (
<>
<p className="studio-result-count">{page.items.length} </p>
{page.items.length ? (
<div className="studio-document-list">
{page.items.map((item) => (
<article className="studio-document-row" key={item.id}>
<p className="studio-row-label">{kindLabel[item.kind]}</p>
<div className="studio-document-title">
<h2>
<Link to={`/studio/documents/${item.id}/edit`}>
{item.title || "제목 없는 작업본"}
</Link>
</h2>
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{publicationLabel[item.publicationStatus]}</dd>
</div>
<div>
<dt></dt>
<dd>{nextLabel[item.nextAction]}</dd>
</div>
<div>
<dt></dt>
<dd>
<time dateTime={item.updatedAt}>
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</dd>
</div>
</dl>
</article>
))}
</div>
) : (
<section className="studio-empty-state">
<h2> </h2>
<p> .</p>
<Link to="/studio/documents/new"> </Link>
</section>
)}
{page.nextCursor ? (
<button
className="studio-secondary-button"
type="button"
onClick={() => setCursor(page.nextCursor ?? undefined)}
>
</button>
) : null}
</>
) : null}
</div>
);
}
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import type {
CreateDocumentInput,
RecordKind,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { useStudio } from "../use-studio.ts";
const types = [
{
kind: "CASE",
title: "Case",
description: "문제를 재현하고 검증한 결론을 기록합니다.",
fields: "문제 · 결론 · 환경 · 재현 · 본문",
},
{
kind: "REFERENCE",
title: "Reference",
description: "반복해서 적용할 기술 기준을 정리합니다.",
fields: "목적 · 규칙 · 적용 조건 · 예외 · 예시",
},
{
kind: "QUESTION",
title: "Question",
description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.",
fields: "상태 · 사실 · 가정 · 미지수 · 선택지",
},
] as const;
function emptyDocument(kind: RecordKind): CreateDocumentInput {
const common = {
title: "",
slug: "" as const,
summary: "",
topicId: null,
projectId: null,
relations: [],
};
if (kind === "CASE") {
return {
...common,
kind,
problem: "",
conclusion: "",
environment: "",
reproduction: "",
lastVerifiedOn: null,
bodyMarkdown: "",
};
}
if (kind === "REFERENCE") {
return {
...common,
kind,
purpose: "",
rules: [],
applyWhen: [],
exceptions: [],
examples: [],
verifiedOn: null,
};
}
return {
...common,
kind,
questionStatus: null,
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
options: [],
nextValidation: "",
resolution: null,
};
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function NewDocumentForm() {
const { gateway, navigateInternal, setRequestAnnouncement } = useStudio();
const [kind, setKind] = useState<RecordKind>("CASE");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const locked = useRef(false);
const activeRequest = useRef<AbortController | null>(null);
useEffect(() => () => activeRequest.current?.abort(), []);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (locked.current) return;
locked.current = true;
setPending(true);
setError("");
const controller = new AbortController();
activeRequest.current = controller;
try {
const document = await gateway.createDocument(emptyDocument(kind), {
idempotencyKey: createLocalId("studio-create"),
signal: controller.signal,
});
setRequestAnnouncement(
`${types.find((type) => type.kind === kind)?.title} 작업본을 만들었습니다.`,
);
navigateInternal(`/studio/documents/${document.id}/edit`);
} catch (reason) {
if (!isAbortError(reason)) {
setError("작업본을 만들지 못했습니다. 다시 시도해 주세요.");
}
} finally {
if (activeRequest.current === controller) activeRequest.current = null;
locked.current = false;
setPending(false);
}
};
return (
<div className="studio-page studio-new-page">
<header className="studio-page-heading">
<p className="studio-eyebrow">NEW WORKING COPY</p>
<h1> </h1>
<p>
.
</p>
</header>
<form
onSubmit={(event) => {
void submit(event);
}}
>
<fieldset className="studio-type-list">
<legend> </legend>
{types.map((type) => (
<label key={type.kind}>
<input
type="radio"
name="kind"
value={type.kind}
checked={kind === type.kind}
onChange={() => setKind(type.kind)}
/>
<strong>{type.title}</strong>
<span>{type.description}</span>
<small>{type.fields}</small>
</label>
))}
</fieldset>
<div className="studio-create-footer">
<button className="studio-primary-button" type="submit" disabled={pending}>
{pending ? "만드는 중…" : "작업본 만들기"}
</button>
<p> Studio .</p>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
</form>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,74 @@
import { useId, useState } from "react";
import { Link } from "react-router-dom";
const navigation = [
{
href: "/studio/documents",
label: "작업본",
active: (path: string) =>
path.startsWith("/studio/documents") && path !== "/studio/documents/new",
},
{
href: "/studio/publications",
label: "게시 기록",
active: (path: string) => path.startsWith("/studio/publications"),
},
{
href: "/studio/documents/new",
label: "새 문서",
active: (path: string) => path === "/studio/documents/new",
},
] as const;
function StudioNavigation({
currentPath,
label,
}: Readonly<{ currentPath: string; label: string }>) {
return (
<nav aria-label={label}>
{navigation.map((item) => (
<Link
key={item.href}
to={item.href}
aria-current={item.active(currentPath) ? "page" : undefined}
>
{item.label}
</Link>
))}
<a href="/"> </a>
</nav>
);
}
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
const [open, setOpen] = useState(false);
const mobileId = useId();
return (
<header className="studio-header">
<div className="studio-header-inner">
<Link
className="studio-wordmark"
to="/studio"
aria-label="TechLog Studio"
>
TechLog <span>Studio</span>
</Link>
<div className="studio-desktop-navigation">
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
</div>
<button
className="studio-menu-trigger"
type="button"
aria-expanded={open}
aria-controls={mobileId}
onClick={() => setOpen((current) => !current)}
>
{open ? "Studio 메뉴 닫기" : "Studio 메뉴 열기"}
</button>
</div>
<div id={mobileId} className="studio-mobile-navigation" hidden={!open}>
<StudioNavigation currentPath={currentPath} label="Studio 모바일 탐색" />
</div>
</header>
);
}
@@ -0,0 +1,5 @@
import { DocumentList } from "../components/document-list.tsx";
export function DocumentsPage() {
return <DocumentList />;
}
@@ -0,0 +1,5 @@
import { NewDocumentForm } from "../components/new-document-form.tsx";
export function NewDocumentPage() {
return <NewDocumentForm />;
}
@@ -0,0 +1,5 @@
import { StudioDashboard } from "../components/studio-dashboard.tsx";
export function StudioHomePage() {
return <StudioDashboard />;
}
@@ -0,0 +1,12 @@
import { Link } from "react-router-dom";
export function StudioNotFoundPage() {
return (
<section className="studio-route-state">
<p className="studio-eyebrow">404</p>
<h1>Studio </h1>
<p> .</p>
<Link to="/studio/documents"> </Link>
</section>
);
}
@@ -0,0 +1,45 @@
import {
type ReactNode,
useCallback,
useMemo,
useState,
} from "react";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import { StudioContext, type StudioContextValue } from "./use-studio.ts";
type StudioProviderProps = Readonly<{
children: ReactNode;
createGateway: () => StudioGateway;
navigate?: (href: string) => void;
}>;
function defaultNavigate(href: string): void {
window.history.pushState({}, "", href);
window.dispatchEvent(new PopStateEvent("popstate"));
}
export function StudioProvider({
children,
createGateway,
navigate = defaultNavigate,
}: StudioProviderProps) {
const [gateway] = useState<StudioGateway>(() => createGateway());
const [requestAnnouncement, setRequestAnnouncement] = useState("");
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
const value = useMemo<StudioContextValue>(
() => ({
gateway,
requestAnnouncement,
setRequestAnnouncement,
navigateInternal,
}),
[gateway, navigateInternal, requestAnnouncement],
);
return (
<StudioContext.Provider value={value}>
<div className="studio-app">{children}</div>
</StudioContext.Provider>
);
}
@@ -0,0 +1,46 @@
import { Component, Fragment, type ReactNode } from "react";
export class StudioRuntimeBoundary extends Component<
Readonly<{ children: ReactNode }>,
Readonly<{ failed: boolean; resetKey: number }>
> {
state = { failed: false, resetKey: 0 };
static getDerivedStateFromError() {
return { failed: true };
}
private retry = () => {
this.setState((current) => ({
failed: false,
resetKey: current.resetKey + 1,
}));
};
render() {
if (this.state.failed) {
return (
<div className="studio-app">
<header className="studio-header">
<div className="studio-header-inner">
<a className="studio-wordmark" href="/studio">
TechLog <span>Studio</span>
</a>
</div>
</header>
<main id="main-content" className="studio-main">
<section className="studio-route-state" role="alert">
<p className="studio-eyebrow">STUDIO ERROR</p>
<h1>Studio </h1>
<p> .</p>
<button type="button" onClick={this.retry}>
</button>
</section>
</main>
</div>
);
}
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>;
}
}
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
import { StudioHeader } from "./components/studio-header.tsx";
import { StudioProvider } from "./studio-provider.tsx";
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
import { useStudio } from "./use-studio.ts";
type StudioShellProps = Readonly<{ children: ReactNode }>;
function StudioFrame({ children }: StudioShellProps) {
const location = useLocation();
const { requestAnnouncement } = useStudio();
return (
<>
<a className="studio-skip-link" href="#main-content">
</a>
<StudioHeader currentPath={location.pathname} />
<main id="main-content" className="studio-main" tabIndex={-1}>
{children}
</main>
<p
className="studio-visually-hidden"
aria-live="polite"
aria-atomic="true"
>
{requestAnnouncement}
</p>
</>
);
}
export function StudioShell({ children }: StudioShellProps) {
const application = useApplication();
const navigate = useNavigate();
const [generation, setGeneration] = useState(0);
const navigateInternal = useCallback(
(href: string) => {
void navigate(href);
},
[navigate],
);
const createGateway = useCallback(
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
[application],
);
useEffect(() => {
const resetPersistedSession = (event: PageTransitionEvent) => {
if (event.persisted) setGeneration((current) => current + 1);
};
window.addEventListener("pageshow", resetPersistedSession);
return () => window.removeEventListener("pageshow", resetPersistedSession);
}, []);
return (
<StudioRuntimeBoundary key={generation}>
<StudioProvider
key={generation}
createGateway={createGateway}
navigate={navigateInternal}
>
<StudioFrame>{children}</StudioFrame>
</StudioProvider>
</StudioRuntimeBoundary>
);
}
@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
export type StudioContextValue = Readonly<{
gateway: StudioGateway;
requestAnnouncement: string;
setRequestAnnouncement(message: string): void;
navigateInternal(href: string): void;
}>;
export const StudioContext = createContext<StudioContextValue | null>(null);
export function useStudio(): StudioContextValue {
const context = useContext(StudioContext);
if (!context) throw new Error("useStudio must be used within StudioProvider");
return context;
}