Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
920 lines
27 KiB
TypeScript
920 lines
27 KiB
TypeScript
"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 { 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 ───────────────────────────────────────── */
|
|
|
|
export function CatalogIcon({
|
|
iconImage,
|
|
size = 20,
|
|
}: {
|
|
iconImage: number;
|
|
size?: number;
|
|
}) {
|
|
const [error, setError] = useState(false);
|
|
|
|
if (error || iconImage <= 0) {
|
|
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />;
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={catalogueIconUrl(iconImage)}
|
|
alt=""
|
|
width={size}
|
|
height={size}
|
|
className="shrink-0 object-contain"
|
|
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
|
|
onError={() => setError(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/* ─── Layout label helper ────────────────────────────────── */
|
|
|
|
export const LAYOUT_COLORS: Record<string, string> = {
|
|
default_3x3:
|
|
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
|
|
frontpage:
|
|
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
|
spaces_new:
|
|
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
|
|
pets: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
|
pets2: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
|
pets3: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
|
|
marketplace: "bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]",
|
|
recycler:
|
|
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
|
|
club_buy:
|
|
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
|
|
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)]",
|
|
};
|
|
|
|
/* ─── 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>
|
|
);
|
|
}
|