feat: add the TechLog Studio asset library route and screen

Adds TECH_LOG_STUDIO_ASSETS (/studio/assets) as a route reachable but
excluded from primary Studio navigation (navigationLabel/navigationOrder
null), plus the AssetLibrary screen that lists assets, shows usage, and
lets an operator archive or hard-delete one. canHardDelete() is a pure
gate mirroring the server's ASSET_IN_USE rule so the screen never offers
an action the server would refuse.

Adding a 28th route also required updating the route-scoped CI
accessibility-evidence gate (FE-GATE-009 in config/ci/gates.json, plus
its authority-baseline counts and shape digest in
scripts/contracts/ci-gates.ts) and the Vite route-to-chunk map that
scripts/generate-build-manifest.ts depends on, or test:unit and the
production build both fail. See task-11-report.md for the full
breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 07:26:05 +09:00
co-authored by Claude Opus 5
parent 073fda87eb
commit b11aa94f1c
12 changed files with 375 additions and 8 deletions
@@ -47,6 +47,7 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_PUBLISH", path: "/studio/documents/:id/publish", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "게시", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from "react";
import type { Asset, AssetDetail } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
/**
* 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은
* 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다.
*/
export function canHardDelete(detail: AssetDetail): boolean {
return (
!detail.hasPublicationHistory &&
detail.usages.length === 0 &&
detail.asset.usageCount === 0
);
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
const [assets, setAssets] = useState<readonly Asset[]>([]);
const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">(
"LOADING",
);
const [selected, setSelected] = useState<AssetDetail | null>(null);
const [pending, setPending] = useState(false);
const [notice, setNotice] = useState("");
const triggerRef = useRef<HTMLButtonElement | null>(null);
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
const openAssetId = useRef<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setListStatus("LOADING");
props.gateway
.listAssets({ limit: 50 }, { signal: controller.signal })
.then((page) => {
setAssets(page.items);
setListStatus("READY");
})
.catch((error: unknown) => {
if (isAbortError(error)) return;
setListStatus("ERROR");
setNotice("Asset 목록을 불러오지 못했습니다.");
});
return () => controller.abort();
}, [props.gateway]);
// A freshly opened detail panel should take focus so keyboard and screen
// reader users land on it without hunting; re-rendering the same asset
// (e.g. after archiving it) must not steal focus back from whatever the
// user is doing next.
useEffect(() => {
if (selected && openAssetId.current !== selected.asset.id) {
detailHeadingRef.current?.focus();
}
openAssetId.current = selected?.asset.id ?? null;
}, [selected]);
async function openDetail(asset: Asset, trigger: HTMLButtonElement) {
triggerRef.current = trigger;
try {
const detail = await props.gateway.getAsset(asset.id);
setSelected(detail);
} catch {
setNotice("Asset 상세를 불러오지 못했습니다.");
}
}
function closeDetail() {
setSelected(null);
queueMicrotask(() => triggerRef.current?.focus());
}
async function archive(detail: AssetDetail) {
setPending(true);
try {
const updated = await props.gateway.updateAssetMetadata(
detail.asset.id,
{ expectedVersion: detail.asset.version, managementStatus: "ARCHIVED" },
{ idempotencyKey: createLocalId("studio-asset-archive") },
);
setAssets((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setSelected({ ...detail, asset: updated });
setNotice("보관했습니다.");
} catch (error) {
setNotice(
isStudioGatewayError(error) ? error.problem.detail : "보관하지 못했습니다.",
);
} finally {
setPending(false);
}
}
async function remove(detail: AssetDetail) {
setPending(true);
try {
await props.gateway.deleteAsset(detail.asset.id, {
idempotencyKey: createLocalId("studio-asset-delete"),
});
setAssets((current) => current.filter((item) => item.id !== detail.asset.id));
setNotice("삭제했습니다.");
closeDetail();
} catch (error) {
setNotice(
isStudioGatewayError(error) ? error.problem.detail : "삭제하지 못했습니다.",
);
} finally {
setPending(false);
}
}
return (
<div className="studio-page studio-assets-page">
<header className="studio-page-heading">
<p className="studio-eyebrow">ASSET LIBRARY</p>
<h1>Asset</h1>
<p>
Asset을 , Asset을
.
</p>
</header>
<p role="status" aria-live="polite">
{notice}
</p>
{listStatus === "LOADING" ? (
<p className="studio-loading" role="status">
Asset .
</p>
) : null}
{listStatus === "ERROR" ? (
<p className="studio-screen-error" role="alert">
Asset .
</p>
) : null}
{listStatus === "READY" && assets.length === 0 ? (
<section className="studio-empty-state">
<h2> Asset이 </h2>
<p>Case Asset을 .</p>
</section>
) : null}
{assets.length > 0 ? (
<ul className="asset-library-list">
{assets.map((asset) => (
<li key={asset.id}>
<button
type="button"
aria-current={selected?.asset.id === asset.id ? "true" : undefined}
onClick={(event) => void openDetail(asset, event.currentTarget)}
>
{asset.assetKey}
</button>
<span>{asset.managementStatus}</span>
<span> {asset.usageCount}</span>
</li>
))}
</ul>
) : null}
{selected ? (
<div className="asset-detail" aria-label={`${selected.asset.assetKey} 상세`}>
<h2 ref={detailHeadingRef} tabIndex={-1}>
{selected.asset.assetKey}
</h2>
<p>{selected.asset.managementStatus}</p>
{selected.usages.length > 0 ? (
<ul>
{selected.usages.map((usage) => (
<li key={usage.documentId}>{usage.title}</li>
))}
</ul>
) : (
<p> .</p>
)}
<div className="asset-detail-actions">
{canHardDelete(selected) ? (
<button type="button" disabled={pending} onClick={() => void remove(selected)}>
</button>
) : (
<button type="button" disabled={pending} onClick={() => void archive(selected)}>
</button>
)}
<button type="button" onClick={closeDetail}>
</button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,9 @@
import { AssetLibrary } from "../components/asset-library.tsx";
import { useStudioAssetGateway } from "../use-studio.ts";
export function AssetsPage() {
// Asset UI must use the throwing accessor, not the nullable `assetGateway`
// field directly -- see `use-studio.ts`'s `useStudioAssetGateway` doc.
const assetGateway = useStudioAssetGateway();
return <AssetLibrary gateway={assetGateway} />;
}
@@ -202,6 +202,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"PublicationPreviewPage",
),
),
TECH_LOG_STUDIO_ASSETS: runtime(
"TECH_LOG_STUDIO_ASSETS",
routeModule(
() => import("./studio/pages/assets-page.tsx"),
"AssetsPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND",
routeModule(