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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Check,
|
Check,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
ListChecks,
|
||||||
Loader2,
|
Loader2,
|
||||||
Package,
|
Package,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
@@ -107,6 +109,39 @@ const GRID_CARD_W = 150;
|
|||||||
const GRID_GAP = 12;
|
const GRID_GAP = 12;
|
||||||
const GRID_ROW_H = 176;
|
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
|
// Table layout: a shared grid template keeps the sticky header and each
|
||||||
// virtualized row in perfect column alignment.
|
// virtualized row in perfect column alignment.
|
||||||
const TABLE_COLS =
|
const TABLE_COLS =
|
||||||
@@ -159,8 +194,20 @@ export function StudioClient({
|
|||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
const [sortBy, setSortBy] = useState<string>("default");
|
const [sortBy, setSortBy] = useState<string>(() =>
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
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 searchRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(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 [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [detail, setDetail] = useState<FurniItem | null>(null);
|
const [detail, setDetail] = useState<FurniItem | null>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(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<{
|
const detailOrigin = useRef<{
|
||||||
top: number;
|
top: number;
|
||||||
trigger: HTMLElement | null;
|
trigger: HTMLElement | null;
|
||||||
@@ -319,6 +372,9 @@ export function StudioClient({
|
|||||||
|
|
||||||
// Infinite scroll: auto-load the next page when the sentinel enters view.
|
// Infinite scroll: auto-load the next page when the sentinel enters view.
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
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 () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -333,7 +389,11 @@ export function StudioClient({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async (search: string, page: number, source?: string) => {
|
async (
|
||||||
|
search: string,
|
||||||
|
page: number,
|
||||||
|
source?: string,
|
||||||
|
): Promise<FurniItem[] | null> => {
|
||||||
listRequest.current?.abort();
|
listRequest.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
listRequest.current = controller;
|
listRequest.current = controller;
|
||||||
@@ -347,19 +407,19 @@ export function StudioClient({
|
|||||||
`/api/admin/import/furni?${qs.toString()}`,
|
`/api/admin/import/furni?${qs.toString()}`,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(`Request failed (${res.status})`);
|
setError(`Request failed (${res.status})`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return null;
|
||||||
|
const fetched: FurniItem[] = data.items ?? [];
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setItems(data.items ?? []);
|
itemsRef.current = fetched;
|
||||||
const available = new Set(
|
setItems(fetched);
|
||||||
(data.items ?? []).map((item: FurniItem) => item.classname),
|
const available = new Set(fetched.map((item) => item.classname));
|
||||||
);
|
|
||||||
setSelected(
|
setSelected(
|
||||||
(previous) =>
|
(previous) =>
|
||||||
new Set(
|
new Set(
|
||||||
@@ -368,17 +428,28 @@ export function StudioClient({
|
|||||||
);
|
);
|
||||||
setDetail((previous) =>
|
setDetail((previous) =>
|
||||||
previous
|
previous
|
||||||
? ((data.items ?? []).find(
|
? (fetched.find(
|
||||||
(item: FurniItem) => item.classname === previous.classname,
|
(item) => item.classname === previous.classname,
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
setMeta(data.meta ?? null);
|
||||||
|
return fetched;
|
||||||
} catch {
|
} catch {
|
||||||
if (!controller.signal.aborted) setError("Network error");
|
if (!controller.signal.aborted) setError("Network error");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -397,6 +468,7 @@ export function StudioClient({
|
|||||||
if (
|
if (
|
||||||
entries.some((e) => e.isIntersecting) &&
|
entries.some((e) => e.isIntersecting) &&
|
||||||
!loading &&
|
!loading &&
|
||||||
|
!loadingAll &&
|
||||||
!batchProgress
|
!batchProgress
|
||||||
) {
|
) {
|
||||||
fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource);
|
fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource);
|
||||||
@@ -413,14 +485,36 @@ export function StudioClient({
|
|||||||
hasMorePages,
|
hasMorePages,
|
||||||
meta?.currentPage,
|
meta?.currentPage,
|
||||||
loading,
|
loading,
|
||||||
|
loadingAll,
|
||||||
batchProgress,
|
batchProgress,
|
||||||
activeSearch,
|
activeSearch,
|
||||||
activeSource,
|
activeSource,
|
||||||
fetchItems,
|
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) => {
|
const changeSource = (sourceId: string) => {
|
||||||
sourceGeneration.current += 1;
|
sourceGeneration.current += 1;
|
||||||
|
listGenRef.current += 1;
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
setEditingClassname(null);
|
setEditingClassname(null);
|
||||||
@@ -541,6 +635,7 @@ export function StudioClient({
|
|||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
if (searchTerm !== activeSearch) {
|
if (searchTerm !== activeSearch) {
|
||||||
|
listGenRef.current += 1;
|
||||||
setActiveSearch(searchTerm);
|
setActiveSearch(searchTerm);
|
||||||
fetchItems(searchTerm, 1, activeSource);
|
fetchItems(searchTerm, 1, activeSource);
|
||||||
}
|
}
|
||||||
@@ -564,12 +659,14 @@ export function StudioClient({
|
|||||||
setSelectedPage(null);
|
setSelectedPage(null);
|
||||||
};
|
};
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
|
listGenRef.current += 1;
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setActiveSearch("");
|
setActiveSearch("");
|
||||||
fetchItems("", 1, activeSource);
|
fetchItems("", 1, activeSource);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
|
listGenRef.current += 1;
|
||||||
setActiveSearch(searchTerm);
|
setActiveSearch(searchTerm);
|
||||||
fetchItems(searchTerm, 1, activeSource);
|
fetchItems(searchTerm, 1, activeSource);
|
||||||
};
|
};
|
||||||
@@ -600,6 +697,63 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}, [allSelected, filteredItems]);
|
}, [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.
|
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -1310,7 +1464,7 @@ export function StudioClient({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
<Select value={sortBy} onValueChange={changeSortBy}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
aria-label="Sort furniture"
|
aria-label="Sort furniture"
|
||||||
className="h-8 w-44 text-xs"
|
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)]">
|
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode("grid")}
|
onClick={() => changeViewMode("grid")}
|
||||||
aria-label="Grid view"
|
aria-label="Grid view"
|
||||||
aria-pressed={viewMode === "grid"}
|
aria-pressed={viewMode === "grid"}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1384,7 +1538,7 @@ export function StudioClient({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode("list")}
|
onClick={() => changeViewMode("list")}
|
||||||
aria-label="List view"
|
aria-label="List view"
|
||||||
aria-pressed={viewMode === "list"}
|
aria-pressed={viewMode === "list"}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1536,6 +1690,24 @@ export function StudioClient({
|
|||||||
{allSelected ? "Deselect visible" : "Select visible"}
|
{allSelected ? "Deselect visible" : "Select visible"}
|
||||||
</button>
|
</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 && (
|
{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)]">
|
<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}
|
) : 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
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
data-testid="studio-furniture-list"
|
data-testid="studio-furniture-list"
|
||||||
@@ -1980,6 +2168,7 @@ export function StudioClient({
|
|||||||
<img
|
<img
|
||||||
src={getFurniImageUrl(item)}
|
src={getFurniImageUrl(item)}
|
||||||
alt=""
|
alt=""
|
||||||
|
decoding="async"
|
||||||
className="h-8 w-8 object-contain"
|
className="h-8 w-8 object-contain"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
Reference in new issue
Block a user