feat: give the Asset Library server search and cursor paging

`StudioAssetGateway.listAssets` has always accepted `q`/`cursor` and
returned `nextCursor`, and this screen's own heading promises search
("업로드한 Asset을 검색하고..."). The component asked for `{ limit: 50 }`
and dropped `nextCursor`, so past the 51st asset older assets were
unmanageable with nothing on screen admitting anything was left out.

Follows `document-list.tsx`'s established shape: a `searchDraft`/`q`
pair so only a submitted query is an effect dependency (exactly one
request per deliberate search, never a trailing one), and a
`nextCursor`-driven control that appends rather than replaces.

Two things the pattern did not already cover:

- `RequestOptions.signal` is advisory, so aborting is not enough to stop
  an abandoned response from painting over a newer one. An `active` flag
  that flips synchronously on dependency change closes that race.
- "더 보기" unmounts exactly when the last page arrives, which would
  strand focus on `<body>`. Focus moves to the first appended row
  unconditionally, falling back to the page heading -- the same anchor
  the delete path already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 13:12:58 +09:00
co-authored by Claude Opus 5
parent 419d9d006d
commit a5825c18b5
3 changed files with 385 additions and 12 deletions
@@ -1,10 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type FormEvent } 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";
/** `document-list.tsx`'s page size for the same screen shape. */
const PAGE_SIZE = 20;
/**
* 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은
* 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다.
@@ -26,32 +29,61 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">(
"LOADING",
);
// Alignment follow-up, item 1. `q` is the *submitted* query, `searchDraft`
// what the user is currently typing. Only `q` is an effect dependency, so
// typing costs nothing -- `document-list.tsx` splits the same pair for the
// same reason, and it is a stronger guarantee than a debounce: exactly one
// request per deliberate search, never a trailing one after the user stops.
const [searchDraft, setSearchDraft] = useState("");
const [q, setQ] = useState("");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [morePending, setMorePending] = useState(false);
const [selected, setSelected] = useState<AssetDetail | null>(null);
const [pending, setPending] = useState(false);
const [notice, setNotice] = useState("");
// Wrapped in an object, not a bare `string | null`: the "no appended row to
// land on" case still needs a focus move (to the page heading), and a bare
// null cannot express "a move is pending, target unknown".
const [moreFocus, setMoreFocus] = useState<{ id: string | null } | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
const pageHeadingRef = useRef<HTMLHeadingElement | null>(null);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const openAssetId = useRef<string | null>(null);
const searchId = useId();
useEffect(() => {
const controller = new AbortController();
// `active` rather than the abort signal alone: `RequestOptions.signal` is
// advisory -- an adapter that ignores it still resolves, and a search the
// user has already replaced would then paint stale rows over the newer
// ones. This flag flips synchronously the moment `q` changes, so an
// abandoned response can never reach `setAssets`.
let active = true;
setListStatus("LOADING");
props.gateway
.listAssets({ limit: 50 }, { signal: controller.signal })
.listAssets(
{ ...(q ? { q } : {}), limit: PAGE_SIZE },
{ signal: controller.signal },
)
.then((page) => {
if (!active) return;
setAssets(page.items);
setNextCursor(page.nextCursor);
setListStatus("READY");
})
.catch((error: unknown) => {
if (isAbortError(error)) return;
if (!active || isAbortError(error)) return;
// The `role="alert"` paragraph below is the sole announcement for a
// load failure -- also routing it through `notice`'s `role="status"`
// paragraph would announce the same sentence twice.
setListStatus("ERROR");
});
return () => controller.abort();
}, [props.gateway]);
return () => {
active = false;
controller.abort();
};
}, [props.gateway, q]);
// A freshly opened detail panel should take focus so keyboard and screen
// reader users land on it without hunting; re-rendering the same asset
@@ -64,6 +96,21 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
openAssetId.current = selected?.asset.id ?? null;
}, [selected]);
// Alignment follow-up, item 1. "더 보기" disappears once the server reports
// no further cursor, so keeping focus on it would strand focus on `<body>`
// exactly when the last page arrives. Moving to the first appended row is
// unconditional -- it cannot depend on whether the control survived -- and
// it puts the user at the content they asked for. Run as an effect (not
// inline after `await`) so the row is committed and its ref attached.
useEffect(() => {
if (!moreFocus) return;
const row = moreFocus.id ? rowRefs.current.get(moreFocus.id) : undefined;
// The page heading is the fallback the delete path already relies on: the
// one node in this screen that survives every list change.
(row ?? pageHeadingRef.current)?.focus();
setMoreFocus(null);
}, [moreFocus]);
async function openDetail(asset: Asset, trigger: HTMLButtonElement) {
triggerRef.current = trigger;
try {
@@ -74,6 +121,50 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
}
}
function submitSearch(event: FormEvent) {
event.preventDefault();
// A new query restarts paging: `nextCursor` was issued against the old
// query's ordering and means nothing under the new one.
setSelected(null);
setNextCursor(null);
setQ(searchDraft.trim());
}
async function loadMore() {
if (!nextCursor || morePending) return;
const cursor = nextCursor;
setMorePending(true);
try {
const page = await props.gateway.listAssets({
...(q ? { q } : {}),
cursor,
limit: PAGE_SIZE,
});
// Append, never replace -- and drop anything already on screen so a
// server that re-sends a boundary row cannot produce a duplicate React
// key (or a duplicate row for the reader). The dedupe lives inside the
// updater so it reads the committed list; the focus target is derived
// separately from the rendered `assets`, because a state updater is not
// invoked synchronously and must stay free of side effects.
const known = new Set(assets.map((item) => item.id));
const appended = page.items.filter((item) => !known.has(item.id));
setAssets((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(page.nextCursor);
setMoreFocus({ id: appended[0]?.id ?? page.items[0]?.id ?? null });
} catch (error) {
setNotice(
isStudioGatewayError(error)
? error.problem.detail
: "다음 Asset을 불러오지 못했습니다.",
);
} finally {
setMorePending(false);
}
}
// Dismissing the panel without changing the list (cancel/close): the row
// that opened it is still on screen, so restore focus there.
function closeDetail() {
@@ -146,6 +237,22 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
</p>
</header>
<section className="studio-asset-tools" aria-label="Asset 검색 도구">
<form 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>
</section>
<p role="status" aria-live="polite">
{notice}
</p>
@@ -160,10 +267,24 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
Asset .
</p>
) : null}
{/* The list changing is otherwise silent: a search replaces every row
and "더 보기" appends without moving anything the reader can see. */}
<p className="studio-result-count" role="status">
{listStatus === "READY" ? `${assets.length}개의 Asset` : ""}
</p>
{listStatus === "READY" && assets.length === 0 ? (
<section className="studio-empty-state">
<h2> Asset이 </h2>
<p>Case Asset을 .</p>
{q ? (
<>
<h2> Asset이 </h2>
<p> Asset을 .</p>
</>
) : (
<>
<h2> Asset이 </h2>
<p>Case Asset을 .</p>
</>
)}
</section>
) : null}
@@ -173,6 +294,11 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
<li key={asset.id}>
<button
type="button"
ref={(node) => {
const rows = rowRefs.current;
if (node) rows.set(asset.id, node);
else rows.delete(asset.id);
}}
aria-current={selected?.asset.id === asset.id ? "true" : undefined}
onClick={(event) => void openDetail(asset, event.currentTarget)}
>
@@ -185,6 +311,17 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
</ul>
) : null}
{nextCursor && listStatus === "READY" ? (
<button
className="studio-secondary-button"
type="button"
disabled={morePending}
onClick={() => void loadMore()}
>
</button>
) : null}
{selected ? (
<div className="asset-detail" aria-label={`${selected.asset.assetKey} 상세`}>
<h2 ref={detailHeadingRef} tabIndex={-1}>
@@ -51,6 +51,7 @@
.studio-app .studio-primary-button,
.studio-app .studio-secondary-button,
.studio-app .studio-document-tools button,
.studio-app .studio-asset-tools button,
.studio-app .studio-empty-state a { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; padding-inline: 16px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-primary-action,
.studio-app .studio-primary-button { margin-bottom: 42px; border-color: var(--signal); background: var(--signal); color: #fff; }
@@ -76,12 +77,18 @@
.studio-app .studio-screen-error { margin-top: 26px; padding: 16px; border-left: 3px solid #a13b31; background: #fff7f4; color: #75271f; }
.studio-app .studio-document-tools { display: grid; grid-template-columns: minmax(280px, 1fr) 180px 180px; gap: 16px; padding-block: 28px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-asset-tools { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; padding-block: 28px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-document-tools form,
.studio-app .studio-document-tools label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .studio-document-tools form div { display: flex; gap: 8px; }
.studio-app .studio-document-tools label,
.studio-app .studio-asset-tools form,
.studio-app .studio-asset-tools label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .studio-document-tools form div,
.studio-app .studio-asset-tools form div { display: flex; gap: 8px; }
.studio-app .studio-document-tools input,
.studio-app .studio-document-tools select { width: 100%; min-width: 0; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
.studio-app .studio-document-tools input { flex: 1; }
.studio-app .studio-document-tools select,
.studio-app .studio-asset-tools input { width: 100%; min-width: 0; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
.studio-app .studio-document-tools input,
.studio-app .studio-asset-tools input { flex: 1; }
.studio-app .studio-result-count { margin: 24px 0 10px; color: var(--muted); font-size: 13px; }
.studio-app .studio-document-row { display: grid; grid-template-columns: 105px minmax(240px, 1fr) minmax(360px, 0.9fr); gap: 24px; align-items: start; padding-block: 26px; border-top: 1px solid var(--line); }
.studio-app .studio-document-list > :first-child { border-top: 0; }