Files
tech-log-frontend/src/features/tech-log/presentation/studio/components/asset-picker.tsx
T
DongHyeonkaandClaude Opus 5 f69edb633d feat: search the server from the Asset Picker without shrinking preview's catalog
The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.

The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.

They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.

A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:16:31 +09:00

147 lines
5.7 KiB
TypeScript

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<readonly Asset[]>([]);
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 <div className="asset-picker">
{/* 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. */}
<form className="asset-picker-search" role="search" onSubmit={submitSearch}>
<label htmlFor={searchId}>Asset 검색</label>
<div>
<input
id={searchId}
type="search"
value={searchDraft}
placeholder="Asset 키, 파일 이름"
onChange={(event) => setSearchDraft(event.target.value)}
/>
<button type="submit">검색</button>
</div>
</form>
{status === "ERROR"
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
{selectable.length > 0 ? <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> : null}
</div>;
}