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:
co-authored by
Claude Opus 5
parent
073fda87eb
commit
b11aa94f1c
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user