import { useEffect, useId, useState, type FormEvent } from "react"; import type { Asset } from "../../../contracts/studio/contract.ts"; import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; /** One screenful of candidates; searching, not scrolling, reaches the rest. */ const PAGE_SIZE = 50; /** * 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(); } function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } 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, onAssetsObserved, }: Readonly<{ gateway: StudioAssetGateway; onInsert: (directive: string) => void; /** * Every Asset this Picker has *seen* -- the first page, and each search's * results -- reported as it arrives. Deliberately not "the Assets to show": * the editor screen merges these into a resolution catalog that only grows * (`mergeAssetCatalog`), because Instant Preview resolves `:::evidence` * directives against that catalog and a narrowing search must never make an * already-inserted figure stop rendering. Optional so Task 10 Step 1's * original test (which renders `AssetPicker` on its own) keeps passing. */ onAssetsObserved?: (assets: readonly Asset[]) => void; }>) { const [assets, setAssets] = useState([]); const [status, setStatus] = useState<"LOADING" | "ERROR" | "READY">("LOADING"); // Alignment follow-up, item 2. `searchDraft` is what the author is typing, // `q` the query actually submitted -- only `q` is an effect dependency. That // is a stronger guarantee than debouncing (zero requests while typing rather // than fewer, and no trailing request after the author stops), and it is the // pair `document-list.tsx` already uses for the same job. const [searchDraft, setSearchDraft] = useState(""); const [q, setQ] = useState(""); const searchId = useId(); useEffect(() => { const controller = new AbortController(); // `RequestOptions.signal` is advisory: an adapter that ignores it still // resolves, and an abandoned query's response would then replace the newer // one's results. This flag flips synchronously when `q` changes, so an // older search can never land last. let active = true; setStatus("LOADING"); gateway .listAssets( { managementStatus: "READY", ...(q ? { q } : {}), limit: PAGE_SIZE }, { signal: controller.signal }, ) .then((page) => { if (!active) return; setAssets(page.items); setStatus("READY"); onAssetsObserved?.(page.items); }) .catch((error: unknown) => { if (!active || isAbortError(error)) return; setStatus("ERROR"); }); return () => { active = false; controller.abort(); }; }, [gateway, onAssetsObserved, q]); // READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다. // A query does not relax this: an asset under review is never insertable, // however it was found. const selectable = assets.filter((asset) => asset.managementStatus === "READY"); const submitSearch = (event: FormEvent) => { event.preventDefault(); setQ(searchDraft.trim()); }; const listMessage = status === "LOADING" ? "Asset 목록을 불러오는 중입니다." : selectable.length > 0 ? `삽입할 수 있는 Asset ${selectable.length}개` : q ? "검색 결과가 없습니다. 다른 검색어를 입력하세요." : "삽입할 수 있는 Asset이 없습니다. 먼저 업로드하세요."; return
{/* The search box stays mounted through the error and empty states: it is the only way to reach an asset outside the first page, so removing it with the list would strand the author on whatever went wrong. */}
setSearchDraft(event.target.value)} />
{status === "ERROR" ?

Asset 목록을 불러오지 못했습니다.

:

{listMessage}

} {selectable.length > 0 ? : null}
; }