feat: insert evidence directives from the TechLog asset picker

Adds an Asset Picker and upload dialog to the CASE editor so authors can
insert `:::evidence` directives that reference backend assets, and opens
projectWorkingCopy's two evidence gates so Instant Preview accepts a key
backed by a freshly loaded READY asset instead of only the one hardcoded
legacy key. The editor screen now owns the loaded Asset list so the
Picker, the upload dialog, and Instant Preview all read the same array,
and a freshly uploaded asset appears in the preview without a refetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 04:32:56 +09:00
co-authored by Claude Opus 5
parent e2c1d076f4
commit 54d9bf9120
11 changed files with 907 additions and 12 deletions
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
/**
* A directive attribute value is delimited by double quotes (see
* `parse-case-content.ts`'s `attributesOf`), so a value containing one would
* corrupt the directive -- and a raw newline would spill the directive across
* lines the block-directive parser does not expect inside an attribute. Both
* are stripped/folded rather than escaped: there is no escape syntax the
* parser accepts inside a directive attribute.
*/
function attributeValue(raw: string): string {
return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim();
}
export function buildEvidenceDirective(
input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>,
): string {
const key = attributeValue(input.assetKey);
const alt = attributeValue(input.alt);
const caption = attributeValue(input.caption);
return `:::evidence key="${key}" alt="${alt}" caption="${caption}" zoom="${input.zoom}"\n:::`;
}
export function AssetPicker({
gateway,
onInsert,
onLoaded,
}: Readonly<{
gateway: StudioAssetGateway;
onInsert: (directive: string) => void;
/**
* Optional so Task 10 Step 1's original test (which renders `AssetPicker`
* without it) keeps passing. When supplied, the editor screen uses this to
* own the same Asset list the Instant Preview resolver reads -- so a
* directive this Picker just inserted renders immediately instead of as a
* placeholder.
*/
onLoaded?: (assets: readonly Asset[]) => void;
}>) {
const [assets, setAssets] = useState<readonly Asset[]>([]);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
gateway
.listAssets({ managementStatus: "READY", limit: 50 }, { signal: controller.signal })
.then((page) => {
setAssets(page.items);
onLoaded?.(page.items);
})
.catch(() => setFailed(true));
return () => controller.abort();
}, [gateway, onLoaded]);
// READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다.
const selectable = assets.filter((asset) => asset.managementStatus === "READY");
if (failed) return <p className="studio-error">Asset .</p>;
if (selectable.length === 0) {
return <p className="studio-asset-picker-empty"> Asset이 . .</p>;
}
return <div className="asset-picker">
<ul>
{selectable.map((asset) => <li key={asset.id}>
<button
type="button"
onClick={() => onInsert(buildEvidenceDirective({
assetKey: asset.assetKey,
alt: asset.decorative ? "" : (asset.altText ?? ""),
caption: "",
zoom: asset.kind === "DIAGRAM",
}))}
>
{asset.assetKey}
</button>
</li>)}
</ul>
</div>;
}