feat(studio): bulk select all results, persist view prefs, back-to-top
This commit is contained in:
1 parent
b92f897ba2
commit
641b6b8cb8
1 file changed
+206
-17
@@ -3,6 +3,7 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
CircleAlert,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
@@ -107,6 +109,39 @@ const GRID_CARD_W = 150;
|
||||
const GRID_GAP = 12;
|
||||
const GRID_ROW_H = 176;
|
||||
|
||||
// Persisted UI preferences (survive reloads).
|
||||
const LS_VIEW_MODE = "studio:viewMode";
|
||||
const LS_SORT_BY = "studio:sortBy";
|
||||
const SORT_KEYS = [
|
||||
"default",
|
||||
"name",
|
||||
"classname",
|
||||
"category",
|
||||
"imported-first",
|
||||
"not-imported-first",
|
||||
] as const;
|
||||
function readLs<T extends string>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
valid: ReadonlyArray<T>,
|
||||
): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
return valid.includes(raw as T) ? (raw as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function writeLs(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* private mode / quota — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Table layout: a shared grid template keeps the sticky header and each
|
||||
// virtualized row in perfect column alignment.
|
||||
const TABLE_COLS =
|
||||
@@ -159,8 +194,20 @@ export function StudioClient({
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||
const [sortBy, setSortBy] = useState<string>(() =>
|
||||
readLs(LS_SORT_BY, "default", SORT_KEYS),
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">(() =>
|
||||
readLs(LS_VIEW_MODE, "list", ["grid", "list"] as const),
|
||||
);
|
||||
const changeViewMode = (mode: "grid" | "list") => {
|
||||
setViewMode(mode);
|
||||
writeLs(LS_VIEW_MODE, mode);
|
||||
};
|
||||
const changeSortBy = (value: string) => {
|
||||
setSortBy(value);
|
||||
writeLs(LS_SORT_BY, value);
|
||||
};
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -171,6 +218,12 @@ export function StudioClient({
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [detail, setDetail] = useState<FurniItem | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// Mirrors `items` synchronously so "select all results" can read the
|
||||
// fully-loaded list right after the last page resolves.
|
||||
const itemsRef = useRef<FurniItem[]>([]);
|
||||
// Bumped when the list query changes (search/source) to cancel a running
|
||||
// bulk "select all" loop.
|
||||
const listGenRef = useRef(0);
|
||||
const detailOrigin = useRef<{
|
||||
top: number;
|
||||
trigger: HTMLElement | null;
|
||||
@@ -319,6 +372,9 @@ export function StudioClient({
|
||||
|
||||
// Infinite scroll: auto-load the next page when the sentinel enters view.
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
// Bulk "select all results" run state; guards the sentinel while pages load.
|
||||
const [loadingAll, setLoadingAll] = useState(false);
|
||||
const loadingAllRef = useRef(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
@@ -333,7 +389,11 @@ export function StudioClient({
|
||||
}, []);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (search: string, page: number, source?: string) => {
|
||||
async (
|
||||
search: string,
|
||||
page: number,
|
||||
source?: string,
|
||||
): Promise<FurniItem[] | null> => {
|
||||
listRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listRequest.current = controller;
|
||||
@@ -347,19 +407,19 @@ export function StudioClient({
|
||||
`/api/admin/import/furni?${qs.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (controller.signal.aborted) return null;
|
||||
if (!res.ok) {
|
||||
setError(`Request failed (${res.status})`);
|
||||
setLoading(false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (controller.signal.aborted) return;
|
||||
if (controller.signal.aborted) return null;
|
||||
const fetched: FurniItem[] = data.items ?? [];
|
||||
if (page === 1) {
|
||||
setItems(data.items ?? []);
|
||||
const available = new Set(
|
||||
(data.items ?? []).map((item: FurniItem) => item.classname),
|
||||
);
|
||||
itemsRef.current = fetched;
|
||||
setItems(fetched);
|
||||
const available = new Set(fetched.map((item) => item.classname));
|
||||
setSelected(
|
||||
(previous) =>
|
||||
new Set(
|
||||
@@ -368,17 +428,28 @@ export function StudioClient({
|
||||
);
|
||||
setDetail((previous) =>
|
||||
previous
|
||||
? ((data.items ?? []).find(
|
||||
(item: FurniItem) => item.classname === previous.classname,
|
||||
? (fetched.find(
|
||||
(item) => item.classname === previous.classname,
|
||||
) ?? null)
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...(data.items ?? [])]);
|
||||
// Pages can arrive out of order (infinite scroll + select-all races);
|
||||
// skip classnames that are already present.
|
||||
const prev = itemsRef.current;
|
||||
const seen = new Set(prev.map((item) => item.classname));
|
||||
const fresh = fetched.filter((item) => !seen.has(item.classname));
|
||||
if (fresh.length > 0) {
|
||||
const next = [...prev, ...fresh];
|
||||
itemsRef.current = next;
|
||||
setItems(next);
|
||||
}
|
||||
}
|
||||
setMeta(data.meta ?? null);
|
||||
return fetched;
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError("Network error");
|
||||
return null;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
@@ -397,6 +468,7 @@ export function StudioClient({
|
||||
if (
|
||||
entries.some((e) => e.isIntersecting) &&
|
||||
!loading &&
|
||||
!loadingAll &&
|
||||
!batchProgress
|
||||
) {
|
||||
fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource);
|
||||
@@ -413,14 +485,36 @@ export function StudioClient({
|
||||
hasMorePages,
|
||||
meta?.currentPage,
|
||||
loading,
|
||||
loadingAll,
|
||||
batchProgress,
|
||||
activeSearch,
|
||||
activeSource,
|
||||
fetchItems,
|
||||
]);
|
||||
|
||||
// Floating "back to top" affordance once the list is long.
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
setShowBackToTop(el.scrollTop > 600);
|
||||
});
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const changeSource = (sourceId: string) => {
|
||||
sourceGeneration.current += 1;
|
||||
listGenRef.current += 1;
|
||||
setConfirmDelete(false);
|
||||
setDeleting(null);
|
||||
setEditingClassname(null);
|
||||
@@ -541,6 +635,7 @@ export function StudioClient({
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (searchTerm !== activeSearch) {
|
||||
listGenRef.current += 1;
|
||||
setActiveSearch(searchTerm);
|
||||
fetchItems(searchTerm, 1, activeSource);
|
||||
}
|
||||
@@ -564,12 +659,14 @@ export function StudioClient({
|
||||
setSelectedPage(null);
|
||||
};
|
||||
const clearSearch = () => {
|
||||
listGenRef.current += 1;
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
fetchItems("", 1, activeSource);
|
||||
};
|
||||
|
||||
const submitSearch = () => {
|
||||
listGenRef.current += 1;
|
||||
setActiveSearch(searchTerm);
|
||||
fetchItems(searchTerm, 1, activeSource);
|
||||
};
|
||||
@@ -600,6 +697,63 @@ export function StudioClient({
|
||||
}
|
||||
}, [allSelected, filteredItems]);
|
||||
|
||||
// Bulk "select every matching result": loads all remaining pages in the
|
||||
// background, then selects whatever matches the active filters. Cancels
|
||||
// itself when the query (search/source) changes or the request is aborted.
|
||||
const selectAllResults = useCallback(async () => {
|
||||
if (loadingAllRef.current) return;
|
||||
const gen = listGenRef.current;
|
||||
const search = activeSearch;
|
||||
const source = activeSource;
|
||||
const first = meta?.currentPage ?? 1;
|
||||
const last = meta?.lastPage ?? first;
|
||||
if (first >= last) {
|
||||
// Everything is already loaded — just select the visible matches.
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const item of filteredItems) next.add(item.classname);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
loadingAllRef.current = true;
|
||||
setLoadingAll(true);
|
||||
try {
|
||||
for (let page = first + 1; page <= last; page += 1) {
|
||||
if (listGenRef.current !== gen) break;
|
||||
const got = await fetchItems(search, page, source);
|
||||
if (!got) break;
|
||||
}
|
||||
if (listGenRef.current === gen) {
|
||||
const matching = filterFurniture(itemsRef.current, {
|
||||
typeFilter,
|
||||
categoryFilter,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
});
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const item of matching) next.add(item.classname);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
loadingAllRef.current = false;
|
||||
setLoadingAll(false);
|
||||
}
|
||||
}, [
|
||||
activeSearch,
|
||||
activeSource,
|
||||
meta?.currentPage,
|
||||
meta?.lastPage,
|
||||
filteredItems,
|
||||
typeFilter,
|
||||
categoryFilter,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
fetchItems,
|
||||
]);
|
||||
|
||||
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -1310,7 +1464,7 @@ export function StudioClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<Select value={sortBy} onValueChange={changeSortBy}>
|
||||
<SelectTrigger
|
||||
aria-label="Sort furniture"
|
||||
className="h-8 w-44 text-xs"
|
||||
@@ -1370,7 +1524,7 @@ export function StudioClient({
|
||||
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("grid")}
|
||||
onClick={() => changeViewMode("grid")}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
className={cn(
|
||||
@@ -1384,7 +1538,7 @@ export function StudioClient({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
onClick={() => changeViewMode("list")}
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === "list"}
|
||||
className={cn(
|
||||
@@ -1536,6 +1690,24 @@ export function StudioClient({
|
||||
{allSelected ? "Deselect visible" : "Select visible"}
|
||||
</button>
|
||||
|
||||
{hasMorePages && meta && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAllResults}
|
||||
disabled={loadingAll}
|
||||
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)] transition-colors hover:bg-[var(--admin-accent)]/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loadingAll ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<ListChecks size={13} />
|
||||
)}
|
||||
{loadingAll
|
||||
? "Loading all…"
|
||||
: `Select all ${meta.total.toLocaleString()} results`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
|
||||
{
|
||||
@@ -1661,7 +1833,23 @@ export function StudioClient({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
listRef.current?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
aria-label="Scroll to top"
|
||||
title="Scroll to top"
|
||||
className={cn(
|
||||
"absolute bottom-4 right-4 z-10 grid h-9 w-9 place-items-center rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text-muted)] shadow-lg transition-all hover:text-[var(--admin-text)]",
|
||||
showBackToTop
|
||||
? "translate-y-0 opacity-100"
|
||||
: "pointer-events-none translate-y-2 opacity-0",
|
||||
)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</button>
|
||||
<div
|
||||
ref={listRef}
|
||||
data-testid="studio-furniture-list"
|
||||
@@ -1980,6 +2168,7 @@ export function StudioClient({
|
||||
<img
|
||||
src={getFurniImageUrl(item)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
className="h-8 w-8 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
loading="lazy"
|
||||
|
||||
Reference in new issue
Block a user