From a5825c18b51b2b739271cb16ee67e70e34d8ecd3 Mon Sep 17 00:00:00 2001
From: DongHyeonka
Date: Tue, 18 Aug 2026 13:12:58 +0900
Subject: [PATCH] feat: give the Asset Library server search and cursor paging
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`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 ``. 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)
---
.../studio/components/asset-library.tsx | 151 +++++++++++-
.../tech-log/presentation/styles/studio.css | 15 +-
.../features/tech-log/asset-library.test.tsx | 231 +++++++++++++++++-
3 files changed, 385 insertions(+), 12 deletions(-)
diff --git a/src/features/tech-log/presentation/studio/components/asset-library.tsx b/src/features/tech-log/presentation/studio/components/asset-library.tsx
index a795c8e..1f656fc 100644
--- a/src/features/tech-log/presentation/studio/components/asset-library.tsx
+++ b/src/features/tech-log/presentation/studio/components/asset-library.tsx
@@ -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(null);
+ const [morePending, setMorePending] = useState(false);
const [selected, setSelected] = useState(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(null);
const detailHeadingRef = useRef(null);
const pageHeadingRef = useRef(null);
+ const rowRefs = useRef(new Map());
const openAssetId = useRef(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 ``
+ // 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 }>) {
+
+
+
+
{notice}
@@ -160,10 +267,24 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
Asset 목록을 불러오지 못했습니다.
) : null}
+ {/* The list changing is otherwise silent: a search replaces every row
+ and "더 보기" appends without moving anything the reader can see. */}
+