feat(catalog): make the live catalog self-correcting and honest about failure
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The previous commit made imports update the Studio without a reload, but the guarantee only held inside the tab that started the import and only as long as every read succeeded. Four holes were left, and this closes them. A session that mounted the tree before an import kept the pre-import tree for the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session no-op. It now asks the server whether what it holds is still current. The answer is a revision: sendCatalogUpdate() already runs after every catalog write, so it bumps one, and clients read it on mount, on focus, on a 20s poll and from other tabs over a BroadcastChannel. An import that finishes in another tab, another browser or the job worker now lands here too. A failed read used to be swallowed, which is the worst outcome available: the rail kept showing pre-import counts as if they were current and nothing said so. The snapshot now carries the error, the rail shows it with a retry, and the previous tree stays on screen because stale beats empty. Every settled import pulled the entire flat tree, which is the one payload that grows with the size of the catalog. The revision doubles as the ETag on mode=full, so an unchanged catalog answers 304 and the poll costs a file read. An import could also report success for an offer the hotel will never sell: a hidden or disabled page, an item_ids that misses the furni id, a zero amount. importSingleFurni reads its own row back and reports each of those as a warning, where the import report already is, instead of leaving it to surface as "the import did not work" in the client. Finally, the catalog items table no longer falls back to router.refresh() — onRefresh is now required, so every mutation ends in a refresh of the caller's own data instead of a route re-render that threw away editor state and scroll position. useServerAction keeps its default, because 47 callers across the app depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that kept its own stale tree and three more router.refresh() calls; only CatalogIcon and LAYOUT_COLORS are still imported, so the rest is gone. Tests: the store now covers revisions, 304s, probe failures and error recovery; a jsdom test mounts a consumer and asserts the tree updates in place with no navigation; the old organize-imports e2e asserted nothing about the endpoints the code actually calls, and is replaced by one that asserts a cross-tab write lands in the mounted categories without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
28ce0f911c
commit
9550b3d66f
18 files changed
+1016
-973
No files matched your search
@@ -15,6 +15,7 @@ import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
||||
import {
|
||||
refreshCatalogTree,
|
||||
seedCatalogTotals,
|
||||
useCatalogSync,
|
||||
useLiveCatalog,
|
||||
} from "@/features/catalog/client/use-live-catalog";
|
||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||
@@ -172,6 +173,9 @@ function CatalogManagerInner({
|
||||
// Server totals are the baseline; after an import the live store holds
|
||||
// counts that are newer than both the prop and its 30s server cache.
|
||||
const liveTotals = useLiveCatalog().totals[catalogType];
|
||||
// The dialog runs on its own page too (Studio embed aside), so it cannot rely
|
||||
// on the Studio to keep the shared tree current.
|
||||
useCatalogSync();
|
||||
useEffect(() => {
|
||||
seedCatalogTotals(catalogType, totals);
|
||||
}, [catalogType, totals]);
|
||||
|
||||
@@ -1,103 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
EyeOff as EyeOffIcon,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
Search,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { Package } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||
import { translateCaption } from "@/lib/catalog-translations";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
interface TreeNode {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageLayout: string;
|
||||
childCount: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface CatalogTreeProps {
|
||||
rootPages: TreeNode[];
|
||||
catalogType?: "normal" | "bc";
|
||||
}
|
||||
|
||||
/* ─── Context for expand/collapse all ────────────────────── */
|
||||
|
||||
interface TreeContext {
|
||||
expandSignal: number;
|
||||
collapseSignal: number;
|
||||
filterMode: FilterMode;
|
||||
onToggleEnabled: (node: TreeNode) => void;
|
||||
onToggleVisible: (node: TreeNode) => void;
|
||||
onDeletePage: (node: TreeNode) => void;
|
||||
searchQuery: string;
|
||||
catalogType: "normal" | "bc";
|
||||
}
|
||||
|
||||
const TreeCtx = createContext<TreeContext>({
|
||||
expandSignal: 0,
|
||||
collapseSignal: 0,
|
||||
filterMode: "all",
|
||||
onToggleEnabled: () => {},
|
||||
onToggleVisible: () => {},
|
||||
onDeletePage: () => {},
|
||||
searchQuery: "",
|
||||
catalogType: "normal",
|
||||
});
|
||||
|
||||
/* ─── Catalog Icon ───────────────────────────────────────── */
|
||||
|
||||
@@ -147,773 +52,5 @@ export const LAYOUT_COLORS: Record<string, string> = {
|
||||
single_bundle:
|
||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||
room_bundle:
|
||||
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
||||
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
||||
};
|
||||
|
||||
/* ─── Highlight matched text ─────────────────────────────── */
|
||||
|
||||
function HighlightText({ text, query }: { text: string; query: string }) {
|
||||
if (!query.trim()) return <>{text}</>;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Tree Component ────────────────────────────────── */
|
||||
|
||||
type FilterMode = "all" | "enabled" | "disabled";
|
||||
|
||||
export function CatalogTree({
|
||||
rootPages,
|
||||
catalogType = "normal",
|
||||
}: CatalogTreeProps) {
|
||||
const router = useRouter();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [expandSignal, setExpandSignal] = useState(0);
|
||||
const [collapseSignal, setCollapseSignal] = useState(0);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||
|
||||
// "/" keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
const res = await adminFetch(
|
||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.pages || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CatalogTree] Search failed:", error);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
};
|
||||
}, [searchQuery, catalogType]);
|
||||
|
||||
// Quick actions from context menu
|
||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
const catBody = catalogType === "bc" ? ', "catalog": "bc"' : "";
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/tree", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`,
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(
|
||||
`${node.caption} ${node.enabled === "1" ? "disabled" : "enabled"}`,
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to toggle. Open the page to edit.");
|
||||
}
|
||||
},
|
||||
[router, catBody],
|
||||
);
|
||||
|
||||
const handleToggleVisible = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/tree", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`,
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(
|
||||
`${node.caption} ${node.visible === "1" ? "hidden" : "visible"}`,
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to toggle. Open the page to edit.");
|
||||
}
|
||||
},
|
||||
[router, catBody],
|
||||
);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
const childInfo =
|
||||
node.childCount > 0
|
||||
? ` Its ${node.childCount} child page(s) will be moved to parent.`
|
||||
: "";
|
||||
const itemInfo =
|
||||
node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : "";
|
||||
const ok = await confirm({
|
||||
title: "Delete page",
|
||||
description: `Delete "${node.caption}"?${childInfo}${itemInfo}`,
|
||||
confirmLabel: "Delete",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await adminFetch(
|
||||
`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(`"${node.caption}" deleted`);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to delete page.");
|
||||
}
|
||||
},
|
||||
[router, catParam, confirm],
|
||||
);
|
||||
|
||||
const unfilteredPages = searchResults ?? rootPages;
|
||||
const displayPages =
|
||||
filterMode === "all"
|
||||
? unfilteredPages
|
||||
: unfilteredPages.filter((p) =>
|
||||
filterMode === "enabled" ? p.enabled === "1" : p.enabled !== "1",
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeCtx.Provider
|
||||
value={{
|
||||
expandSignal,
|
||||
collapseSignal,
|
||||
filterMode,
|
||||
searchQuery: searchResults !== null ? searchQuery : "",
|
||||
onToggleEnabled: handleToggleEnabled,
|
||||
onToggleVisible: handleToggleVisible,
|
||||
onDeletePage: handleDeletePage,
|
||||
catalogType,
|
||||
}}
|
||||
>
|
||||
{confirmDialog}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Catalog Structure</h3>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
{displayPages.length}
|
||||
{filterMode !== "all" ? ` / ${unfilteredPages.length}` : ""}{" "}
|
||||
pages
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Status filter */}
|
||||
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterMode("all")}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium transition-colors",
|
||||
filterMode === "all"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Show all pages</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterMode("enabled")}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
|
||||
filterMode === "enabled"
|
||||
? "bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ToggleRight className="h-3 w-3" />
|
||||
On
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Show enabled only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterMode("disabled")}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
|
||||
filterMode === "disabled"
|
||||
? "bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ToggleLeft className="h-3 w-3" />
|
||||
Off
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Show disabled only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Expand / Collapse all */}
|
||||
{searchResults === null && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setExpandSignal((s) => s + 1)}
|
||||
>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Expand all</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setCollapseSignal((s) => s + 1)}
|
||||
>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Collapse all</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search pages... /"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setSearchQuery("");
|
||||
searchInputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searching ? (
|
||||
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree body */}
|
||||
<div className="p-1.5 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{searchResults !== null && (
|
||||
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{searchResults.length} result
|
||||
{searchResults.length !== 1 ? "s" : ""} for "
|
||||
{searchQuery}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{displayPages.map((page) => (
|
||||
<TreeItem
|
||||
key={page.id}
|
||||
node={page}
|
||||
level={0}
|
||||
isSearchResult={searchResults !== null}
|
||||
defaultExpanded={searchResults === null}
|
||||
/>
|
||||
))}
|
||||
{displayPages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{searchResults !== null
|
||||
? "No pages match your search"
|
||||
: "No catalog pages found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TreeCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tree Item ──────────────────────────────────────────── */
|
||||
|
||||
function TreeItem({
|
||||
node,
|
||||
level,
|
||||
isSearchResult,
|
||||
defaultExpanded,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
level: number;
|
||||
isSearchResult?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
void isSearchResult;
|
||||
const {
|
||||
expandSignal,
|
||||
collapseSignal,
|
||||
filterMode,
|
||||
searchQuery,
|
||||
onToggleEnabled,
|
||||
onToggleVisible,
|
||||
onDeletePage,
|
||||
catalogType,
|
||||
} = useContext(TreeCtx);
|
||||
const router = useRouter();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const hasChildren = node.childCount > 0;
|
||||
|
||||
const loadChildren = useCallback(async () => {
|
||||
if (loaded || !hasChildren) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
const res = await adminFetch(
|
||||
`/api/admin/catalog/tree?parentId=${node.id}${catParam}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setChildren(data.pages || []);
|
||||
setLoaded(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CatalogTree] Failed to load children:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [node.id, loaded, hasChildren, catalogType]);
|
||||
|
||||
// Auto-expand root level on mount
|
||||
useEffect(() => {
|
||||
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
}
|
||||
}, [defaultExpanded, level, hasChildren, loaded, loadChildren]);
|
||||
|
||||
// Expand all signal
|
||||
useEffect(() => {
|
||||
if (expandSignal > 0 && hasChildren) {
|
||||
if (!loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [expandSignal, hasChildren, loaded, loadChildren]);
|
||||
|
||||
// Collapse all signal
|
||||
useEffect(() => {
|
||||
if (collapseSignal > 0) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [collapseSignal]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!expanded && !loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
return;
|
||||
}
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
const isDisabled = node.enabled !== "1";
|
||||
const isHidden = node.visible !== "1";
|
||||
const isEmpty = node.itemCount === 0 && node.childCount === 0;
|
||||
const layoutColor =
|
||||
LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
|
||||
const italianName = translateCaption(node.caption);
|
||||
const displayName = italianName ?? node.caption;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default",
|
||||
isDisabled && "opacity-40",
|
||||
isEmpty && !isDisabled && "opacity-60",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||
onDoubleClick={() =>
|
||||
router.push(
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog/builder-club/${node.id}`
|
||||
: `/admin/catalog/${node.id}`,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
router.push(
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog/builder-club/${node.id}`
|
||||
: `/admin/catalog/${node.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand/collapse toggle */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Catalog icon */}
|
||||
<CatalogIcon iconImage={node.iconImage} />
|
||||
|
||||
{/* Caption link */}
|
||||
<Link
|
||||
href={
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog/builder-club/${node.id}`
|
||||
: `/admin/catalog/${node.id}`
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 hover:text-primary transition-colors",
|
||||
isEmpty && !isDisabled
|
||||
? "text-muted-foreground italic"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate block",
|
||||
!isEmpty && "font-medium",
|
||||
)}
|
||||
>
|
||||
<HighlightText text={displayName} query={searchQuery} />
|
||||
</span>
|
||||
{italianName && (
|
||||
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
|
||||
<HighlightText text={node.caption} query={searchQuery} />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Quick toggle enabled on hover */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleEnabled(node);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
isDisabled
|
||||
? "hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]"
|
||||
: "hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]",
|
||||
)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Power className="h-3 w-3" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{isDisabled ? "Enable" : "Disable"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Quick add subpage */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog?catalog=bc&newPageParent=${node.id}`
|
||||
: `/admin/catalog?newPageParent=${node.id}`,
|
||||
);
|
||||
}}
|
||||
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Create subpage</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isHidden && (
|
||||
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-4 px-1 text-[9px]"
|
||||
>
|
||||
OFF
|
||||
</Badge>
|
||||
)}
|
||||
{node.itemCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] font-mono"
|
||||
>
|
||||
{node.itemCount}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Layout badge on hover */}
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0",
|
||||
layoutColor,
|
||||
)}
|
||||
>
|
||||
{node.pageLayout.replace("default_3x3", "3x3")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs max-w-72 p-3">
|
||||
<p className="font-semibold mb-1.5">
|
||||
{node.caption}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
#{node.id}
|
||||
</span>
|
||||
</p>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">Layout</td>
|
||||
<td className="py-px">{node.pageLayout}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">Items</td>
|
||||
<td className="py-px">{node.itemCount}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">
|
||||
Subpages
|
||||
</td>
|
||||
<td className="py-px">{node.childCount}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">Icon</td>
|
||||
<td className="py-px">{node.iconImage}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">
|
||||
Visible
|
||||
</td>
|
||||
<td className="py-px">
|
||||
{node.visible === "1" ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-muted-foreground pr-3 py-px">
|
||||
Enabled
|
||||
</td>
|
||||
<td className="py-px">
|
||||
{node.enabled === "1" ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-muted-foreground mt-2 text-[10px]">
|
||||
Right-click for actions · Double-click to open
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
{/* Right-click context menu */}
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuLabel>
|
||||
{node.caption} #{node.id}
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
router.push(
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog/builder-club/${node.id}`
|
||||
: `/admin/catalog/${node.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit Page
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(String(node.id))}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy ID
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onToggleEnabled(node)}>
|
||||
{node.enabled === "1" ? (
|
||||
<PowerOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
{node.enabled === "1" ? "Disable" : "Enable"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onToggleVisible(node)}>
|
||||
{node.visible === "1" ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{node.visible === "1" ? "Hide" : "Show"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
router.push(
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog?catalog=bc&newPageParent=${node.id}`
|
||||
: `/admin/catalog?newPageParent=${node.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Subpage
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeletePage(node)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Children */}
|
||||
{expanded &&
|
||||
children.length > 0 &&
|
||||
(() => {
|
||||
const filteredChildren =
|
||||
filterMode === "all"
|
||||
? children
|
||||
: children.filter((c) =>
|
||||
filterMode === "enabled"
|
||||
? c.enabled === "1"
|
||||
: c.enabled !== "1",
|
||||
);
|
||||
return filteredChildren.length > 0 ? (
|
||||
<div className="relative">
|
||||
{/* Tree guide line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 20 + 18}px` }}
|
||||
/>
|
||||
{filteredChildren.map((child) => (
|
||||
<TreeItem key={child.id} node={child} level={level + 1} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -81,6 +80,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog";
|
||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||
import {
|
||||
discoverOffers,
|
||||
@@ -154,7 +154,13 @@ function SortableRow({
|
||||
interface CatalogItemsTableProps {
|
||||
initialSearch?: string;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onRefresh?: () => void;
|
||||
/**
|
||||
* Required, not optional: every mutation here has to end in a refresh of the
|
||||
* caller's own data. The table used to fall back to `router.refresh()` when
|
||||
* this was omitted, which re-rendered the whole route to redraw one table —
|
||||
* and lost the editor state and scroll position that go with it.
|
||||
*/
|
||||
onRefresh: () => void;
|
||||
items: CatalogItemData[];
|
||||
pageId: number;
|
||||
pageLayout: string;
|
||||
@@ -176,7 +182,6 @@ export function CatalogItemsTable({
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
}: CatalogItemsTableProps) {
|
||||
const router = useRouter();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||
const t = useTranslations(
|
||||
@@ -455,8 +460,7 @@ export function CatalogItemsTable({
|
||||
errorMessage: `Failed to save item #${item.id}.`,
|
||||
onSuccess: () => {
|
||||
setSavingItemId(null);
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
onError: () => setSavingItemId(null),
|
||||
},
|
||||
@@ -500,8 +504,7 @@ export function CatalogItemsTable({
|
||||
}
|
||||
|
||||
if (savedCount > 0) {
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return {
|
||||
@@ -533,8 +536,7 @@ export function CatalogItemsTable({
|
||||
errorMessage: "Failed to delete items.",
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -568,8 +570,7 @@ export function CatalogItemsTable({
|
||||
setSelected(new Set());
|
||||
setMoveOpen(false);
|
||||
setMoveTargetPageId(null);
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -630,6 +631,7 @@ export function CatalogItemsTable({
|
||||
run(() => reorderCatalogItems({ orders }), {
|
||||
successMessage: "Item order saved.",
|
||||
errorMessage: "Failed to save order.",
|
||||
onSuccess: () => onRefresh(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -669,8 +671,7 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setAddOpen(false);
|
||||
setNewItem({ ...DEFAULT_NEW_ITEM });
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -686,6 +687,7 @@ export function CatalogItemsTable({
|
||||
run(() => deleteCatalogItems({ ids: [id] }), {
|
||||
successMessage: "Item deleted.",
|
||||
errorMessage: "Failed to delete item.",
|
||||
onSuccess: () => onRefresh(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -721,8 +723,7 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setMoveOneId(null);
|
||||
setMoveOneTarget(null);
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -751,6 +752,7 @@ export function CatalogItemsTable({
|
||||
{
|
||||
successMessage: `Item #${item.id} duplicated.`,
|
||||
errorMessage: "Failed to duplicate item.",
|
||||
onSuccess: () => onRefresh(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -792,8 +794,7 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setEditingItem(null);
|
||||
setEditingBase(null);
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -882,8 +883,7 @@ export function CatalogItemsTable({
|
||||
onApplied={() => {
|
||||
setBulkRefreshFrom(items);
|
||||
setSelected(new Set());
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
onRefresh();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -971,7 +971,10 @@ export function CatalogItemsTable({
|
||||
/>
|
||||
<BulkImportItems
|
||||
pageId={pageId}
|
||||
onImported={() => router.refresh()}
|
||||
onImported={() => {
|
||||
onRefresh();
|
||||
void refreshCatalogTree();
|
||||
}}
|
||||
/>
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
refreshCatalogTree,
|
||||
useLiveCatalog,
|
||||
} from "@/features/catalog/client/use-live-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
|
||||
/**
|
||||
* A refresh that fails must be visible. Every number and category below is
|
||||
* presented as current, so silently keeping the previous tree after a failed
|
||||
* read would show pre-import data as if nothing had happened — and a staff
|
||||
* member has no other signal that the import they just ran is not on screen.
|
||||
*/
|
||||
function LiveCatalogNotice() {
|
||||
const { refreshError, refreshing } = useLiveCatalog();
|
||||
const [retrying, startRetry] = useTransition();
|
||||
if (!refreshError && !refreshing) return null;
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className={cn(
|
||||
"mx-3 mb-2 flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs",
|
||||
refreshError
|
||||
? "border-destructive/40 bg-destructive/10 text-destructive"
|
||||
: "text-[var(--admin-text-muted)]",
|
||||
)}
|
||||
>
|
||||
{refreshError ? (
|
||||
<>
|
||||
<AlertTriangle size={14} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1">Catalog could not refresh.</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={retrying}
|
||||
onClick={() =>
|
||||
startRetry(async () => {
|
||||
await refreshCatalogTree();
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-medium underline disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={12} className={cn(retrying && "animate-spin")} />
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 size={14} className="shrink-0 animate-spin" />
|
||||
<span className="min-w-0 flex-1">Updating catalog…</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export const CatalogRail = memo(function CatalogRail({
|
||||
tree,
|
||||
selectedId,
|
||||
@@ -77,6 +138,7 @@ export const CatalogRail = memo(function CatalogRail({
|
||||
>
|
||||
All categories
|
||||
</button>
|
||||
<LiveCatalogNotice />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{visible.length === 0 && (
|
||||
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
|
||||
|
||||
@@ -65,8 +65,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ensureCatalogTreeLoaded,
|
||||
refreshCatalogTree,
|
||||
useCatalogSync,
|
||||
useLiveCatalog,
|
||||
} from "@/features/catalog/client/use-live-catalog";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -202,9 +202,9 @@ export function StudioClient({
|
||||
const tree = useLiveCatalog().tree;
|
||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
useCatalogSync();
|
||||
useEffect(() => {
|
||||
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
||||
void ensureCatalogTreeLoaded();
|
||||
}, []);
|
||||
|
||||
// Search & list
|
||||
|
||||
Reference in new issue
Block a user