feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -1,919 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user