revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s

revert style: clean up code with prettier and eslint
This commit is contained in:
remco committed 2026-07-12 21:02:03 +02:00
1 parent 27de078f54
commit e85e4d74ea
378 files changed
+20710 -22428

No files matched your search

@@ -1,31 +1,31 @@
"use client";
'use client'
import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
import { ChevronRight, Home } from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const { state, dispatch } = useCatalogManager()
const { selectedPageId, ancestors } = state
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
dispatch({ type: 'SET_ANCESTORS', ancestors: [] })
return
}
const ac = new AbortController();
const ac = new AbortController()
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { signal: ac.signal })
.then((r) => r.json())
.then((d) => {
if (!ac.signal.aborted) {
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
dispatch({ type: 'SET_ANCESTORS', ancestors: d.ancestors ?? [] })
}
})
.catch(() => {});
return () => ac.abort();
}, [selectedPageId, dispatch]);
.catch(() => {})
return () => ac.abort()
}, [selectedPageId, dispatch])
if (!selectedPageId || ancestors.length === 0) return null;
if (!selectedPageId || ancestors.length === 0) return null
return (
<div className="flex items-center gap-1 px-4 py-1 border-b bg-muted/20 text-xs text-muted-foreground overflow-x-auto shrink-0">
@@ -35,7 +35,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
onClick={() => dispatch({ type: 'SELECT_PAGE', id: a.id })}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
@@ -44,15 +44,17 @@ export function BreadcrumbBar() {
))}
{/* Current page */}
{(() => {
const node = state.nodes.get(selectedPageId);
if (!node) return null;
const node = state.nodes.get(selectedPageId)
if (!node) return null
return (
<>
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="shrink-0 text-foreground font-medium truncate max-w-40">{node.caption}</span>
<span className="shrink-0 text-foreground font-medium truncate max-w-40">
{node.caption}
</span>
</>
);
)
})()}
</div>
);
)
}
@@ -1,22 +1,28 @@
"use client";
'use client'
import { LayoutGrid } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
import { RootTabs } from "./root-tabs";
import { SortableTree } from "./sortable-tree";
import { StatsBar } from "./stats-bar";
import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
import { LayoutGrid } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogPage } from '@/actions/catalog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { RootTab } from '@/types/catalog'
import { BreadcrumbBar } from './breadcrumb-bar'
import { InlineEditor, type PageUpdateData } from './inline-editor'
import { RootTabs } from './root-tabs'
import { SortableTree } from './sortable-tree'
import { StatsBar } from './stats-bar'
import { CatalogManagerProvider, useCatalogManager } from './use-catalog-store'
export function CatalogManagerDialog() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -35,7 +41,7 @@ export function CatalogManagerDialog() {
</CatalogManagerProvider>
</DialogContent>
</Dialog>
);
)
}
function CatalogManagerInner({
@@ -43,93 +49,96 @@ function CatalogManagerInner({
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
open: boolean
onOpenChange: (v: boolean) => void
}) {
const { state, dispatch } = useCatalogManager();
const { state, dispatch } = useCatalogManager()
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
dispatch({ type: 'SET_LOADING_ROOT', loading: true })
try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error();
const data = await res.json();
const res = await fetch('/api/admin/catalog/tree?parentId=-1')
if (!res.ok) throw new Error()
const data = await res.json()
const pages: RootTab[] = (data.pages || []).map((p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
}))
dispatch({ type: 'SET_ROOT_PAGES', pages })
// Auto-select first tab if none or missing
if (pages.length > 0 && (!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
if (
pages.length > 0 &&
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))
) {
dispatch({ type: 'SET_ACTIVE_TAB', id: pages[0].id })
}
} catch {
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
toast.error('Failed to load catalog root pages')
dispatch({ type: 'SET_LOADING_ROOT', loading: false })
}
}, [dispatch, state.activeTabId]);
}, [dispatch, state.activeTabId])
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
if (open) loadRootPages()
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
// Handle dialog close: preserve tree state, clear selection
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
if (!open) dispatch({ type: 'ON_DIALOG_CLOSE' })
}, [open, dispatch])
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) return;
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
};
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SET_ACTIVE_TAB', id })
dispatch({ type: 'CLEAR_TREE' })
}
const handleCreateRoot = async () => {
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
const caption = prompt('Root page name:')
if (!caption?.trim()) return
try {
const result = await createCatalogPage({ parentId: -1, caption: caption.trim() });
if (!result.ok) throw new Error(result.error);
await loadRootPages();
toast.success("Root page created");
const result = await createCatalogPage({ parentId: -1, caption: caption.trim() })
if (!result.ok) throw new Error(result.error)
await loadRootPages()
toast.success('Root page created')
} catch {
toast.error("Failed to create root page");
toast.error('Failed to create root page')
}
};
}
const handleSelectPage = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) return;
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SELECT_PAGE', id: id > 0 ? id : null })
}
const handleEditTab = (id: number) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
dispatch({ type: 'SET_ACTIVE_TAB', id })
dispatch({ type: 'CLEAR_TREE' })
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) return;
dispatch({ type: "SELECT_PAGE", id });
};
if (state.editorDirty && !confirm('You have unsaved changes. Discard?')) return
dispatch({ type: 'SELECT_PAGE', id })
}
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
dispatch({ type: 'UPDATE_NODE', update: { ...updated } })
dispatch({ type: 'SET_DIRTY', dirty: false })
// Update root tabs if root page
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id)
if (rootIdx !== -1) {
const pages = [...state.rootPages];
pages[rootIdx] = { ...pages[rootIdx], caption: updated.caption, enabled: updated.enabled };
dispatch({ type: "SET_ROOT_PAGES", pages });
const pages = [...state.rootPages]
pages[rootIdx] = { ...pages[rootIdx], caption: updated.caption, enabled: updated.enabled }
dispatch({ type: 'SET_ROOT_PAGES', pages })
}
};
}
return (
<TooltipProvider delayDuration={300}>
@@ -169,5 +178,5 @@ function CatalogManagerInner({
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
);
)
}
@@ -1,88 +1,90 @@
"use client";
'use client'
import { ImageOff, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ImageOff, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface ImagePreviewProps {
value: string;
basePath?: string;
height?: number;
className?: string;
value: string
basePath?: string
height?: number
className?: string
}
export function ImagePreview({
value,
basePath = "/swf/c_images/catalogue",
basePath = '/swf/c_images/catalogue',
height = 60,
className,
}: ImagePreviewProps) {
const [src, setSrc] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "loaded" | "error">("idle");
const [fallback, setFallback] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [src, setSrc] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
const [fallback, setFallback] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!value.trim()) {
setSrc("");
setStatus("idle");
setFallback(false);
return;
setSrc('')
setStatus('idle')
setFallback(false)
return
}
if (debounceRef.current) clearTimeout(debounceRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setFallback(false);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
setFallback(false)
const url = `${basePath}/${value.trim()}.png`
setSrc(url)
setStatus('loading')
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value, basePath]);
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [value, basePath])
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
setFallback(true)
setSrc(`${basePath}/${value.trim()}.gif`)
setStatus('loading')
} else {
setStatus("error");
setStatus('error')
}
}
if (status === "idle" || !src) {
if (status === 'idle' || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ""}`}
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ''}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
);
)
}
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ""}`}
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ''}`}
style={{ height }}
>
{status === "loading" && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />}
{status === 'loading' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onLoad={() => setStatus("loaded")}
style={{ imageRendering: 'pixelated' }}
onLoad={() => setStatus('loaded')}
onError={handleError}
/>
{status === "error" && (
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
)
}
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
AlertCircle,
@@ -10,127 +10,133 @@ import {
Package,
Save,
Settings,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogItemsTable } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
import { useCatalogManager } from "./use-catalog-store";
} from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { updateCatalogPage } from '@/actions/catalog'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogItemsTable } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogTranslateTab } from '@/app/admin/catalog/[id]/catalog-translate-tab'
import { BulkImportItems } from '@/components/admin/catalog/bulk-import-items'
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
import { IconPicker } from '@/components/admin/catalog/icon-picker'
import { ItemsShopPreview } from '@/components/admin/catalog/items-shop-preview'
import { QuickAddFurni } from '@/components/admin/catalog/quick-add-furni'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
import { cn } from '@/lib/utils'
import { ImagePreview } from './image-preview'
import { useCatalogManager } from './use-catalog-store'
interface PageDetail {
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
itemCount?: number;
id: number
caption: string
parentId: number
pageLayout: string
enabled: string
visible: string
minRank: number
clubOnly: string
orderNum: number
iconImage: number
iconColor: number
pageHeadline: string
pageTeaser: string
pageSpecial: string | null
pageText1: string | null
pageText2: string | null
pageTextDetails: string | null
pageTextTeaser: string | null
itemCount?: number
}
interface ItemsData {
items: CatalogItemData[];
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[];
catalogNameMap: Record<number, string>;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[];
allPages: { id: number; caption: string }[];
items: CatalogItemData[]
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
catalogNameMap: Record<number, string>
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
interactionTypes: string[]
allPages: { id: number; caption: string }[]
}
export interface PageUpdateData {
id: number;
caption: string;
enabled: string;
visible: string;
pageLayout: string;
iconImage: number;
id: number
caption: string
enabled: string
visible: string
pageLayout: string
iconImage: number
}
interface InlineEditorProps {
pageId: number | null;
onSaved: (updated: PageUpdateData) => void;
pageId: number | null
onSaved: (updated: PageUpdateData) => void
}
type FormState = Record<string, string | number | null>;
type FormState = Record<string, string | number | null>
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch } = useCatalogManager();
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
const [activeTab, setActiveTab] = useState("settings");
const [showPreview, setShowPreview] = useState(false);
const { dispatch } = useCatalogManager()
const [page, setPage] = useState<PageDetail | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<FormState>({})
const [original, setOriginal] = useState<FormState>({})
const [showTexts, setShowTexts] = useState(false)
const [activeTab, setActiveTab] = useState('settings')
const [showPreview, setShowPreview] = useState(false)
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
const [itemsData, setItemsData] = useState<ItemsData | null>(null)
const [itemsLoading, setItemsLoading] = useState(false)
const [itemsLoaded, setItemsLoaded] = useState(false)
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
return Object.keys(form).some((k) => form[k] !== original[k]);
}, [form, original]);
return Object.keys(form).some((k) => form[k] !== original[k])
}, [form, original])
useEffect(() => {
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
dispatch({ type: 'SET_DIRTY', dirty: isDirty })
}, [isDirty, dispatch])
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (isDirty && !saving && page) handleSave();
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (isDirty && !saving && page) handleSave()
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
});
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}) // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => {
setLoading(true);
setLoading(true)
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
const p = data.page as PageDetail
setPage(p)
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
@@ -141,84 +147,84 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
pageHeadline: p.pageHeadline || '',
pageTeaser: p.pageTeaser || '',
pageSpecial: p.pageSpecial || '',
pageText1: p.pageText1 || '',
pageText2: p.pageText2 || '',
pageTextDetails: p.pageTextDetails || '',
pageTextTeaser: p.pageTextTeaser || '',
}
setForm(formData)
setOriginal(formData)
} catch {
toast.error("Failed to load page");
setPage(null);
toast.error('Failed to load page')
setPage(null)
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
setItemsLoading(true)
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setItemsData(data as ItemsData);
setItemsLoaded(true);
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
setItemsData(data as ItemsData)
setItemsLoaded(true)
} catch {
toast.error("Failed to load items");
toast.error('Failed to load items')
} finally {
setItemsLoading(false);
setItemsLoading(false)
}
}, []);
}, [])
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId);
}, [pageId, loadItemsData]);
if (pageId && pageId > 0) loadItemsData(pageId)
}, [pageId, loadItemsData])
useEffect(() => {
if (pageId && pageId > 0) {
loadPage(pageId);
setItemsData(null);
setItemsLoaded(false);
setActiveTab("settings");
setShowPreview(false);
loadPage(pageId)
setItemsData(null)
setItemsLoaded(false)
setActiveTab('settings')
setShowPreview(false)
} else {
setPage(null);
setForm({});
setOriginal({});
setItemsData(null);
setItemsLoaded(false);
setPage(null)
setForm({})
setOriginal({})
setItemsData(null)
setItemsLoaded(false)
}
}, [pageId, loadPage]);
}, [pageId, loadPage])
// Lazy-load items
useEffect(() => {
if (
(activeTab === "items" || activeTab === "translate") &&
(activeTab === 'items' || activeTab === 'translate') &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
pageId > 0
) {
loadItemsData(pageId);
loadItemsData(pageId)
}
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]);
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData])
function update(key: string, value: string | number | null) {
setForm((prev) => ({ ...prev, [key]: value }));
setForm((prev) => ({ ...prev, [key]: value }))
}
async function handleSave() {
if (!page) return;
setSaving(true);
if (!page) return
setSaving(true)
try {
const result = await updateCatalogPage({ id: page.id, ...form });
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
setOriginal({ ...form });
const result = await updateCatalogPage({ id: page.id, ...form })
if (!result.ok) throw new Error(result.error || 'Failed to save')
toast.success('Page updated & catalog synced')
setOriginal({ ...form })
setPage((prev) =>
prev
? {
@@ -230,7 +236,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
iconImage: (form.iconImage as number) ?? prev.iconImage,
}
: prev,
);
)
onSaved({
id: page.id,
caption: String(form.caption || page.caption),
@@ -238,11 +244,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
visible: String(form.visible ?? page.visible),
pageLayout: String(form.pageLayout || page.pageLayout),
iconImage: (form.iconImage as number) ?? page.iconImage,
});
})
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
toast.error(e instanceof Error ? e.message : 'Failed to save')
} finally {
setSaving(false);
setSaving(false)
}
}
@@ -251,7 +257,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Select a page from the tree to edit
</div>
);
)
}
if (loading) {
@@ -259,12 +265,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
)
}
if (!page) return null;
if (!page) return null
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || 'bg-muted text-muted-foreground'
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -275,13 +281,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{(form.caption as string) || page.caption}</h3>
<Badge className={cn("h-4 px-1 text-[8px] font-mono border-0", layoutColor)}>
<Badge className={cn('h-4 px-1 text-[8px] font-mono border-0', layoutColor)}>
{form.pageLayout as string}
</Badge>
{isDirty && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
unsaved
@@ -289,7 +295,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
)}
</div>
<p className="text-xs text-muted-foreground">
#{page.id} &middot; Parent: {page.parentId === -1 ? "Root" : `#${page.parentId}`}
#{page.id} &middot; Parent: {page.parentId === -1 ? 'Root' : `#${page.parentId}`}
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
</p>
</div>
@@ -307,8 +313,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to save
Unsaved changes — press{' '}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to
save
</span>
<Button
size="sm"
@@ -317,7 +324,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={handleSave}
disabled={saving}
>
{saving ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <Save className="mr-1 h-3 w-3" />}
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Save className="mr-1 h-3 w-3" />
)}
Save
</Button>
</div>
@@ -329,7 +340,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />}
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
@@ -345,13 +356,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList);
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length;
const furniSet = new Set(itemsData.furniDataIdList)
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
) : null
})()}
</TabsTrigger>
</TabsList>
@@ -364,33 +375,36 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-1.5">
<Label className="text-xs">Caption</Label>
<Input
value={String(form.caption || "")}
onChange={(e) => update("caption", e.target.value)}
value={String(form.caption || '')}
onChange={(e) => update('caption', e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select value={String(form.pageLayout || "")} onValueChange={(v) => update("pageLayout", v)}>
<Select
value={String(form.pageLayout || '')}
onValueChange={(v) => update('pageLayout', v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color = LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
const color = LAYOUT_COLORS[l] || 'bg-muted text-muted-foreground'
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span className={cn("inline-block h-2 w-2 rounded-full", color)} />
<span className={cn('inline-block h-2 w-2 rounded-full', color)} />
{l}
</span>
</SelectItem>
);
)
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? "Custom layout"}
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? 'Custom layout'}
</p>
</div>
</div>
@@ -402,7 +416,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Input
type="number"
value={form.orderNum as number}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
className="h-8 text-sm"
/>
</div>
@@ -411,7 +425,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Input
type="number"
value={form.minRank as number}
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)}
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
className="h-8 text-sm"
/>
</div>
@@ -419,7 +433,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update("iconImage", v)}
onChange={(v) => update('iconImage', v)}
/>
</div>
</div>
@@ -430,22 +444,22 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
id="ie-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
checked={form.enabled === '1'}
onChange={(v) => update('enabled', v ? '1' : '0')}
/>
<ToggleRow
id="ie-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
checked={form.visible === '1'}
onChange={(v) => update('visible', v ? '1' : '0')}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
checked={form.clubOnly === '1'}
onChange={(v) => update('clubOnly', v ? '1' : '0')}
/>
</div>
@@ -453,24 +467,24 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || "")}
onChange={(v) => update("pageHeadline", v)}
value={String(form.pageHeadline || '')}
onChange={(v) => update('pageHeadline', v)}
type="header"
label="Headline image"
/>
<ImagePreview value={String(form.pageHeadline || "")} height={50} />
<ImagePreview value={String(form.pageHeadline || '')} height={50} />
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || "")}
onChange={(v) => update("pageTeaser", v)}
value={String(form.pageTeaser || '')}
onChange={(v) => update('pageTeaser', v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview value={String(form.pageTeaser || "")} height={50} />
<ImagePreview value={String(form.pageTeaser || '')} height={50} />
</div>
{/* Collapsible text fields */}
@@ -479,7 +493,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
@@ -494,8 +512,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || "")}
onChange={(e) => update("pageText1", e.target.value || null)}
value={String(form.pageText1 || '')}
onChange={(e) => update('pageText1', e.target.value || null)}
className="text-sm"
/>
</div>
@@ -503,8 +521,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || "")}
onChange={(e) => update("pageText2", e.target.value || null)}
value={String(form.pageText2 || '')}
onChange={(e) => update('pageText2', e.target.value || null)}
className="text-sm"
/>
</div>
@@ -512,16 +530,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || "")}
onChange={(e) => update("pageTextDetails", e.target.value || null)}
value={String(form.pageTextDetails || '')}
onChange={(e) => update('pageTextDetails', e.target.value || null)}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
value={String(form.pageSpecial || "")}
onChange={(e) => update("pageSpecial", e.target.value || null)}
value={String(form.pageSpecial || '')}
onChange={(e) => update('pageSpecial', e.target.value || null)}
className="h-8 text-sm"
/>
</div>
@@ -560,7 +578,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={() => setShowPreview(!showPreview)}
className="ml-auto"
>
{showPreview ? "Hide Preview" : "Shop Preview"}
{showPreview ? 'Hide Preview' : 'Shop Preview'}
</Button>
</div>
@@ -607,7 +625,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</TabsContent>
</Tabs>
</div>
);
)
}
// ── Toggle row (inline) ─────────────────────────────────────
@@ -619,11 +637,11 @@ function ToggleRow({
checked,
onChange,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
id: string
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-start justify-between gap-3">
@@ -635,5 +653,5 @@ function ToggleRow({
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
</div>
);
)
}
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
closestCenter,
@@ -9,93 +9,105 @@ import {
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { deleteTreePage, reorderTreePage, toggleCatalogPage } from "@/actions/catalog";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button";
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { deleteTreePage, reorderTreePage, toggleCatalogPage } from '@/actions/catalog'
import { CatalogIcon } from '@/components/admin/catalog-tree'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
} from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type { RootTab } from "@/types/catalog";
export type { RootTab } from '@/types/catalog'
import type { RootTab } from "@/types/catalog";
import type { RootTab } from '@/types/catalog'
interface RootTabsProps {
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void;
onEditTab: (id: number) => void;
onCreateRoot: () => void;
loading?: boolean;
tabs: RootTab[]
activeTabId: number | null
onTabClick: (id: number) => void
onEditTab: (id: number) => void
onCreateRoot: () => void
loading?: boolean
}
export function RootTabs({ tabs, activeTabId, onTabClick, onEditTab, onCreateRoot, loading }: RootTabsProps) {
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
const [activeDragId, setActiveDragId] = useState<number | null>(null)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as number);
}, []);
setActiveDragId(event.active.id as number)
}, [])
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
if (!over || active.id === over.id) return;
const { active, over } = event
setActiveDragId(null)
if (!over || active.id === over.id) return
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const oldIndex = tabs.findIndex((t) => t.id === active.id)
const newIndex = tabs.findIndex((t) => t.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(tabs, oldIndex, newIndex);
const newOrder = arrayMove(tabs, oldIndex, newIndex)
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) => reorderTreePage({ pageId: tab.id, newOrderNum: idx + 1 })),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} reorder(s) failed`);
else toast.success("Tabs reordered");
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) toast.error(`${failed} reorder(s) failed`)
else toast.success('Tabs reordered')
} catch {
toast.error("Failed to reorder tabs");
toast.error('Failed to reorder tabs')
}
},
[tabs],
);
)
const handleToggle = async (tab: RootTab) => {
try {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
toast.success(`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`);
await toggleCatalogPage({ id: tab.id, action: 'toggleEnabled' })
toast.success(`${tab.caption} ${tab.enabled === '1' ? 'disabled' : 'enabled'}`)
} catch {
toast.error("Failed to toggle");
toast.error('Failed to toggle')
}
};
}
const handleDelete = async (tab: RootTab) => {
if (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return;
if (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return
try {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
toast.success(`"${tab.caption}" deleted`);
await deleteTreePage({ pageId: tab.id, mode: 'reparent' })
toast.success(`"${tab.caption}" deleted`)
} catch {
toast.error("Failed to delete");
toast.error('Failed to delete')
}
};
}
const draggedTab = activeDragId ? tabs.find((t) => t.id === activeDragId) : null;
const draggedTab = activeDragId ? tabs.find((t) => t.id === activeDragId) : null
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
@@ -120,12 +132,19 @@ export function RootTabs({ tabs, activeTabId, onTabClick, onEditTab, onCreateRoo
))}
</SortableContext>
<DragOverlay>
{draggedTab && <TabButton tab={draggedTab} isActive={activeTabId === draggedTab.id} isOverlay />}
{draggedTab && (
<TabButton tab={draggedTab} isActive={activeTabId === draggedTab.id} isOverlay />
)}
</DragOverlay>
</DndContext>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 ml-1" onClick={onCreateRoot}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -134,7 +153,7 @@ export function RootTabs({ tabs, activeTabId, onTabClick, onEditTab, onCreateRoo
</TooltipContent>
</Tooltip>
</div>
);
)
}
// ── Sortable root tab ─────────────────────────────────────────
@@ -147,22 +166,22 @@ function SortableRootTab({
onToggle,
onDelete,
}: {
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
tab: RootTab
isActive: boolean
onClick: () => void
onEdit: () => void
onToggle: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
})
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
};
}
return (
<ContextMenu>
@@ -178,12 +197,12 @@ function SortableRootTab({
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggle}>
{tab.enabled === "1" ? (
{tab.enabled === '1' ? (
<PowerOff className="mr-2 h-3.5 w-3.5" />
) : (
<Power className="mr-2 h-3.5 w-3.5" />
)}
{tab.enabled === "1" ? "Disable" : "Enable"}
{tab.enabled === '1' ? 'Disable' : 'Enable'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
@@ -192,7 +211,7 @@ function SortableRootTab({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
)
}
// ── Tab button render ─────────────────────────────────────────
@@ -204,11 +223,11 @@ function TabButton({
onClick,
onDoubleClick,
}: {
tab: RootTab;
isActive: boolean;
isOverlay?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
tab: RootTab
isActive: boolean
isOverlay?: boolean
onClick?: () => void
onDoubleClick?: () => void
}) {
return (
<button
@@ -216,16 +235,16 @@ function TabButton({
onClick={onClick}
onDoubleClick={onDoubleClick}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap shrink-0",
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap shrink-0',
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "hover:bg-accent text-muted-foreground hover:text-foreground",
tab.enabled !== "1" && "opacity-50",
isOverlay && "bg-card shadow-lg border",
? 'bg-primary text-primary-foreground shadow-sm'
: 'hover:bg-accent text-muted-foreground hover:text-foreground',
tab.enabled !== '1' && 'opacity-50',
isOverlay && 'bg-card shadow-lg border',
)}
>
<CatalogIcon iconImage={tab.iconImage} size={16} />
<span className="max-w-32 truncate">{tab.caption}</span>
</button>
);
)
}
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
closestCenter,
@@ -9,9 +9,9 @@ import {
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useVirtualizer } from "@tanstack/react-virtual";
} from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
ChevronsDownUp,
ChevronsUpDown,
@@ -26,39 +26,44 @@ import {
ToggleRight,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
createCatalogPage,
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from "@/actions/catalog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { type FlatTreeNode, SortableTreeItem, TreeItemContent, type TreeItemProps } from "./tree-item";
import { useCatalogManager } from "./use-catalog-store";
} from '@/actions/catalog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
type FlatTreeNode,
SortableTreeItem,
TreeItemContent,
type TreeItemProps,
} from './tree-item'
import { useCatalogManager } from './use-catalog-store'
/* ─── Types ──────────────────────────────────────────────── */
export type { TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
export type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
import type { TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
import type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
interface SortableTreeProps {
activeTabId: number | null;
selectedPageId: number | null;
onSelectPage: (id: number) => void;
onTreeMutation: () => void;
nodeUpdate?: TreeNodeUpdate | null;
activeTabId: number | null
selectedPageId: number | null
onSelectPage: (id: number) => void
onTreeMutation: () => void
nodeUpdate?: TreeNodeUpdate | null
}
type FilterMode = "all" | "enabled" | "disabled";
type FilterMode = 'all' | 'enabled' | 'disabled'
/* ─── Component ──────────────────────────────────────────── */
@@ -69,144 +74,147 @@ export function SortableTree({
onTreeMutation,
nodeUpdate,
}: SortableTreeProps) {
const { state, dispatch } = useCatalogManager();
const { nodes, childrenMap, expandedIds, loadingIds, multiSelectedIds, focusedId, sortMode } = state;
const { state, dispatch } = useCatalogManager()
const { nodes, childrenMap, expandedIds, loadingIds, multiSelectedIds, focusedId, sortMode } =
state
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(null);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
const searchInputRef = useRef<HTMLInputElement>(null);
const treeRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(null)
const [searching, setSearching] = useState(false)
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const [activeId, setActiveId] = useState<number | null>(null)
const [treeLoading, setTreeLoading] = useState(false)
const [filter, setFilter] = useState<FilterMode>('all')
const searchInputRef = useRef<HTMLInputElement>(null)
const treeRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
// ── Full tree load (single API call) ────────────────────────
const loadFullTree = useCallback(async () => {
setTreeLoading(true);
setTreeLoading(true)
try {
const res = await fetch("/api/admin/catalog/tree?mode=full");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: TreeNodeData[] = data.pages || [];
const res = await fetch('/api/admin/catalog/tree?mode=full')
if (!res.ok) throw new Error()
const data = await res.json()
const pages: TreeNodeData[] = data.pages || []
// Build nodes map and children map in one pass
const nodesMap = new Map<number, TreeNodeData>();
const childMap = new Map<number, number[]>();
const nodesMap = new Map<number, TreeNodeData>()
const childMap = new Map<number, number[]>()
for (const p of pages) {
nodesMap.set(p.id, p);
const siblings = childMap.get(p.parentId) || [];
siblings.push(p.id);
childMap.set(p.parentId, siblings);
nodesMap.set(p.id, p)
const siblings = childMap.get(p.parentId) || []
siblings.push(p.id)
childMap.set(p.parentId, siblings)
}
dispatch({ type: "SET_NODES", nodes: pages });
dispatch({ type: 'SET_NODES', nodes: pages })
// Set all children maps at once
for (const [parentId, childIds] of childMap) {
const children = childIds.map((id) => nodesMap.get(id)!).filter(Boolean);
dispatch({ type: "SET_CHILDREN", parentId, children });
const children = childIds.map((id) => nodesMap.get(id)!).filter(Boolean)
dispatch({ type: 'SET_CHILDREN', parentId, children })
}
} catch {
toast.error("Failed to load catalog tree");
toast.error('Failed to load catalog tree')
} finally {
setTreeLoading(false);
setTreeLoading(false)
}
}, [dispatch]);
}, [dispatch])
// Load full tree when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (activeTabId === null) return;
dispatch({ type: "CLEAR_TREE" });
setSearchQuery("");
setSearchResults(null);
setFilter("all");
loadFullTree();
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
if (activeTabId === null) return
dispatch({ type: 'CLEAR_TREE' })
setSearchQuery('')
setSearchResults(null)
setFilter('all')
loadFullTree()
}, [activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
setSearchResults(null)
return
}
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (searchTimeout.current) clearTimeout(searchTimeout.current)
searchTimeout.current = setTimeout(async () => {
setSearching(true);
setSearching(true)
try {
const res = await fetch(`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`);
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`,
)
if (res.ok) {
const data = await res.json();
setSearchResults(data.pages || []);
const data = await res.json()
setSearchResults(data.pages || [])
}
} finally {
setSearching(false);
setSearching(false)
}
}, 300);
}, 300)
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
};
}, [searchQuery]);
if (searchTimeout.current) clearTimeout(searchTimeout.current)
}
}, [searchQuery])
// Apply external node updates
useEffect(() => {
if (!nodeUpdate) return;
dispatch({ type: "UPDATE_NODE", update: nodeUpdate });
}, [nodeUpdate, dispatch]);
if (!nodeUpdate) return
dispatch({ type: 'UPDATE_NODE', update: nodeUpdate })
}, [nodeUpdate, dispatch])
// Toggle expand (instant — data already loaded)
const handleToggleExpand = useCallback(
(id: number) => {
if (expandedIds.has(id)) {
dispatch({ type: "SET_EXPANDED", id, expanded: false });
dispatch({ type: 'SET_EXPANDED', id, expanded: false })
} else {
dispatch({ type: "SET_EXPANDED", id, expanded: true });
dispatch({ type: 'SET_EXPANDED', id, expanded: true })
}
},
[expandedIds, dispatch],
);
)
// ── Flatten visible tree with filter ────────────────────────
const flatItems = useMemo((): FlatTreeNode[] => {
if (activeTabId === null) return [];
if (activeTabId === null) return []
if (searchResults) {
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }));
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }))
}
const result: FlatTreeNode[] = [];
const result: FlatTreeNode[] = []
const traverse = (parentId: number, depth: number) => {
const childIds = childrenMap.get(parentId) || [];
const childIds = childrenMap.get(parentId) || []
for (const cid of childIds) {
const node = nodes.get(cid);
if (!node) continue;
const node = nodes.get(cid)
if (!node) continue
// Apply filter
if (filter === "enabled" && node.enabled !== "1") continue;
if (filter === "disabled" && node.enabled === "1") continue;
const isExpanded = expandedIds.has(cid);
result.push({ ...node, depth, isExpanded });
if (isExpanded) traverse(cid, depth + 1);
if (filter === 'enabled' && node.enabled !== '1') continue
if (filter === 'disabled' && node.enabled === '1') continue
const isExpanded = expandedIds.has(cid)
result.push({ ...node, depth, isExpanded })
if (isExpanded) traverse(cid, depth + 1)
}
};
traverse(activeTabId, 0);
return result;
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
}
traverse(activeTabId, 0)
return result
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter])
const flatIds = useMemo(() => flatItems.map((n) => n.id), [flatItems]);
const totalNodes = nodes.size;
const flatIds = useMemo(() => flatItems.map((n) => n.id), [flatItems])
const totalNodes = nodes.size
// ── Ancestor highlight ──────────────────────────────────────
const ancestorIds = useMemo(() => {
const set = new Set<number>();
if (!selectedPageId) return set;
let current = nodes.get(selectedPageId);
const set = new Set<number>()
if (!selectedPageId) return set
let current = nodes.get(selectedPageId)
while (current && current.parentId > 0) {
set.add(current.parentId);
current = nodes.get(current.parentId);
set.add(current.parentId)
current = nodes.get(current.parentId)
}
return set;
}, [selectedPageId, nodes]);
return set
}, [selectedPageId, nodes])
// ── Virtualizer ─────────────────────────────────────────────
const virtualizer = useVirtualizer({
@@ -214,85 +222,86 @@ export function SortableTree({
getScrollElement: () => scrollRef.current,
estimateSize: () => 30,
overscan: 20,
});
})
// Auto-scroll to selected page
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!selectedPageId) return;
const idx = flatIds.indexOf(selectedPageId);
if (!selectedPageId) return
const idx = flatIds.indexOf(selectedPageId)
if (idx >= 0) {
// Delay slightly to let virtualizer settle
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: "center" }));
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: 'center' }))
}
}, [selectedPageId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedPageId]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard navigation ─────────────────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (e.key === "/") {
e.preventDefault();
searchInputRef.current?.focus();
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
}
return;
return
}
if (e.key === "/") {
e.preventDefault();
searchInputRef.current?.focus();
return;
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
return
}
if (e.key === "Escape") {
if (e.key === 'Escape') {
if (searchQuery) {
setSearchQuery("");
setSearchResults(null);
} else dispatch({ type: "CLEAR_MULTI_SELECT" });
return;
setSearchQuery('')
setSearchResults(null)
} else dispatch({ type: 'CLEAR_MULTI_SELECT' })
return
}
const currentIdx = focusedId ? flatIds.indexOf(focusedId) : -1;
const currentIdx = focusedId ? flatIds.indexOf(focusedId) : -1
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIdx = currentIdx < flatIds.length - 1 ? currentIdx + 1 : 0;
dispatch({ type: "SET_FOCUSED", id: flatIds[nextIdx] });
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatIds.length - 1;
dispatch({ type: "SET_FOCUSED", id: flatIds[prevIdx] });
} else if (e.key === "ArrowRight" && focusedId) {
e.preventDefault();
const node = flatItems.find((n) => n.id === focusedId);
if (e.key === 'ArrowDown') {
e.preventDefault()
const nextIdx = currentIdx < flatIds.length - 1 ? currentIdx + 1 : 0
dispatch({ type: 'SET_FOCUSED', id: flatIds[nextIdx] })
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatIds.length - 1
dispatch({ type: 'SET_FOCUSED', id: flatIds[prevIdx] })
} else if (e.key === 'ArrowRight' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
if (node && node.childCount > 0 && !node.isExpanded) {
handleToggleExpand(focusedId);
handleToggleExpand(focusedId)
} else if (node?.isExpanded) {
const children = childrenMap.get(focusedId);
if (children?.[0]) dispatch({ type: "SET_FOCUSED", id: children[0] });
const children = childrenMap.get(focusedId)
if (children?.[0]) dispatch({ type: 'SET_FOCUSED', id: children[0] })
}
} else if (e.key === "ArrowLeft" && focusedId) {
e.preventDefault();
const node = flatItems.find((n) => n.id === focusedId);
} else if (e.key === 'ArrowLeft' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
if (node?.isExpanded) {
dispatch({ type: "SET_EXPANDED", id: focusedId, expanded: false });
dispatch({ type: 'SET_EXPANDED', id: focusedId, expanded: false })
} else {
const parent = nodes.get(focusedId);
if (parent?.parentId && parent.parentId > 0) dispatch({ type: "SET_FOCUSED", id: parent.parentId });
const parent = nodes.get(focusedId)
if (parent?.parentId && parent.parentId > 0)
dispatch({ type: 'SET_FOCUSED', id: parent.parentId })
}
} else if (e.key === "Enter" && focusedId) {
e.preventDefault();
onSelectPage(focusedId);
} else if (e.key === " " && focusedId) {
e.preventDefault();
dispatch({ type: "TOGGLE_MULTI_SELECT", id: focusedId });
} else if (e.key === "Delete" && focusedId) {
e.preventDefault();
handleDelete(focusedId);
} else if (e.key === 'Enter' && focusedId) {
e.preventDefault()
onSelectPage(focusedId)
} else if (e.key === ' ' && focusedId) {
e.preventDefault()
dispatch({ type: 'TOGGLE_MULTI_SELECT', id: focusedId })
} else if (e.key === 'Delete' && focusedId) {
e.preventDefault()
handleDelete(focusedId)
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [
focusedId,
flatIds,
@@ -303,218 +312,219 @@ export function SortableTree({
dispatch,
onSelectPage,
handleToggleExpand,
]);
]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Selection with Ctrl/Shift ───────────────────────────────
const handleSelect = useCallback(
(id: number, e?: React.MouseEvent) => {
if (e?.ctrlKey || e?.metaKey) {
dispatch({ type: "TOGGLE_MULTI_SELECT", id });
dispatch({ type: 'TOGGLE_MULTI_SELECT', id })
} else if (e?.shiftKey && focusedId) {
dispatch({ type: "RANGE_SELECT", from: focusedId, to: id, flatIds });
dispatch({ type: 'RANGE_SELECT', from: focusedId, to: id, flatIds })
} else {
onSelectPage(id);
onSelectPage(id)
}
},
[focusedId, flatIds, dispatch, onSelectPage],
);
)
// ── DnD handlers ────────────────────────────────────────────
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id as number);
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id as number)
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || activeTabId === null) return;
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id || activeTabId === null) return
const activeNode = nodes.get(active.id as number);
const overNode = nodes.get(over.id as number);
if (!activeNode || !overNode) return;
const activeNode = nodes.get(active.id as number)
const overNode = nodes.get(over.id as number)
if (!activeNode || !overNode) return
if (activeNode.parentId === overNode.parentId) {
const parentId = activeNode.parentId;
const siblings = childrenMap.get(parentId) || [];
const oldIndex = siblings.indexOf(active.id as number);
const newIndex = siblings.indexOf(over.id as number);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(siblings, oldIndex, newIndex);
const parentId = activeNode.parentId
const siblings = childrenMap.get(parentId) || []
const oldIndex = siblings.indexOf(active.id as number)
const newIndex = siblings.indexOf(over.id as number)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(siblings, oldIndex, newIndex)
dispatch({
type: "SET_CHILDREN",
type: 'SET_CHILDREN',
parentId,
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
});
})
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) => reorderTreePage({ pageId: id, newOrderNum: idx + 1 })),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
else toast.success("Reordered");
onTreeMutation();
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) toast.error(`${failed} reorder(s) failed`)
else toast.success('Reordered')
onTreeMutation()
} catch {
toast.error("Failed to reorder");
loadFullTree();
toast.error('Failed to reorder')
loadFullTree()
}
} else {
const newParentId = overNode.parentId;
const siblings = childrenMap.get(newParentId) || [];
const overIndex = siblings.indexOf(over.id as number);
const newParentId = overNode.parentId
const siblings = childrenMap.get(newParentId) || []
const overIndex = siblings.indexOf(over.id as number)
try {
const result = await reorderTreePage({
pageId: active.id as number,
newParentId,
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
});
if (!result.ok) throw new Error();
await loadFullTree();
onTreeMutation();
toast.success("Page moved");
})
if (!result.ok) throw new Error()
await loadFullTree()
onTreeMutation()
toast.success('Page moved')
} catch {
toast.error("Failed to move page");
toast.error('Failed to move page')
}
}
};
}
// ── Actions ─────────────────────────────────────────────────
const handleAddSubpage = async (parentId: number) => {
const caption = prompt("Subpage name:");
if (!caption?.trim()) return;
const caption = prompt('Subpage name:')
if (!caption?.trim()) return
try {
const result = await createCatalogPage({ parentId, caption: caption.trim() });
if (!result.ok) throw new Error(result.error);
dispatch({ type: "SET_EXPANDED", id: parentId, expanded: true });
await loadFullTree();
const data = result.data as Record<string, unknown>;
onSelectPage(data.id as number);
onTreeMutation();
const result = await createCatalogPage({ parentId, caption: caption.trim() })
if (!result.ok) throw new Error(result.error)
dispatch({ type: 'SET_EXPANDED', id: parentId, expanded: true })
await loadFullTree()
const data = result.data as Record<string, unknown>
onSelectPage(data.id as number)
onTreeMutation()
} catch {
toast.error("Failed to create subpage");
toast.error('Failed to create subpage')
}
};
}
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
const newEnabled = currentEnabled === "1" ? "0" : "1";
const newEnabled = currentEnabled === '1' ? '0' : '1'
try {
const result = await toggleCatalogPage({ id, action: "toggleEnabled" });
if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } });
onTreeMutation();
const result = await toggleCatalogPage({ id, action: 'toggleEnabled' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: newEnabled } })
onTreeMutation()
} catch {
toast.error("Failed to toggle enabled");
toast.error('Failed to toggle enabled')
}
};
}
const handleToggleVisible = async (id: number, currentVisible: string) => {
const newVisible = currentVisible === "1" ? "0" : "1";
const newVisible = currentVisible === '1' ? '0' : '1'
try {
const result = await toggleCatalogPage({ id, action: "toggleVisible" });
if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } });
onTreeMutation();
const result = await toggleCatalogPage({ id, action: 'toggleVisible' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, visible: newVisible } })
onTreeMutation()
} catch {
toast.error("Failed to toggle visibility");
toast.error('Failed to toggle visibility')
}
};
}
const handleDelete = async (id: number) => {
const node = nodes.get(id);
if (!confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`)) return;
const node = nodes.get(id)
if (!confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`)) return
try {
const result = await deleteTreePage({ pageId: id, mode: "reparent" });
if (!result.ok) throw new Error();
dispatch({ type: "REMOVE_NODE", id });
if (selectedPageId === id) onSelectPage(-1);
await loadFullTree();
onTreeMutation();
const result = await deleteTreePage({ pageId: id, mode: 'reparent' })
if (!result.ok) throw new Error()
dispatch({ type: 'REMOVE_NODE', id })
if (selectedPageId === id) onSelectPage(-1)
await loadFullTree()
onTreeMutation()
} catch {
toast.error("Failed to delete page");
toast.error('Failed to delete page')
}
};
}
const handleDuplicate = async (id: number) => {
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const { page } = await res.json();
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const { page } = await res.json()
const result = await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
pageLayout: page.pageLayout ?? 'default_3x3',
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
enabled: '0',
visible: page.visible ?? '1',
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
});
if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number;
})
if (!result.ok) throw new Error(result.error)
const newId = (result.data as Record<string, unknown>).id as number
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageHeadline: page.pageHeadline ?? '',
pageTeaser: page.pageTeaser ?? '',
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId);
onTreeMutation();
})
await loadFullTree()
toast.success(`Duplicated "${page.caption}" (items not copied)`)
onSelectPage(newId)
onTreeMutation()
} catch {
toast.error("Failed to duplicate page");
toast.error('Failed to duplicate page')
}
};
}
const handleOpenFull = (id: number) => window.open(`/admin/catalog/${id}`, "_blank");
const handleOpenFull = (id: number) => window.open(`/admin/catalog/${id}`, '_blank')
// ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => {
const allIds = Array.from(nodes.keys());
dispatch({ type: "EXPAND_PATH", ids: allIds });
}, [nodes, dispatch]);
const allIds = Array.from(nodes.keys())
dispatch({ type: 'EXPAND_PATH', ids: allIds })
}, [nodes, dispatch])
const handleCollapseAll = useCallback(() => {
dispatch({ type: "COLLAPSE_ALL" });
}, [dispatch]);
dispatch({ type: 'COLLAPSE_ALL' })
}, [dispatch])
// ── Bulk actions ────────────────────────────────────────────
const handleBulkEnable = async (enable: boolean) => {
const ids = [...multiSelectedIds];
const ids = [...multiSelectedIds]
const results = await Promise.allSettled(
ids.map((id) => {
const node = nodes.get(id);
if (!node || (node.enabled === "1") === enable) return Promise.resolve();
return toggleCatalogPage({ id, action: "toggleEnabled" });
const node = nodes.get(id)
if (!node || (node.enabled === '1') === enable) return Promise.resolve()
return toggleCatalogPage({ id, action: 'toggleEnabled' })
}),
);
for (const id of ids) dispatch({ type: "UPDATE_NODE", update: { id, enabled: enable ? "1" : "0" } });
dispatch({ type: "CLEAR_MULTI_SELECT" });
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} toggle(s) failed`);
else toast.success(`${ids.length} page(s) ${enable ? "enabled" : "disabled"}`);
onTreeMutation();
};
)
for (const id of ids)
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: enable ? '1' : '0' } })
dispatch({ type: 'CLEAR_MULTI_SELECT' })
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) toast.error(`${failed} toggle(s) failed`)
else toast.success(`${ids.length} page(s) ${enable ? 'enabled' : 'disabled'}`)
onTreeMutation()
}
const handleBulkDelete = async () => {
const ids = [...multiSelectedIds];
if (!confirm(`Delete ${ids.length} page(s)?`)) return;
await Promise.allSettled(ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })));
dispatch({ type: "CLEAR_MULTI_SELECT" });
await loadFullTree();
toast.success(`${ids.length} page(s) deleted`);
onTreeMutation();
};
const ids = [...multiSelectedIds]
if (!confirm(`Delete ${ids.length} page(s)?`)) return
await Promise.allSettled(ids.map((id) => deleteTreePage({ pageId: id, mode: 'reparent' })))
dispatch({ type: 'CLEAR_MULTI_SELECT' })
await loadFullTree()
toast.success(`${ids.length} page(s) deleted`)
onTreeMutation()
}
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
const noop = () => {};
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null
const noop = () => {}
// Shared props builder for tree items
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
const getItemProps = useCallback(
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
(node: FlatTreeNode): Omit<TreeItemProps, 'sortMode' | 'isOverlay'> => ({
node,
isSelected: selectedPageId === node.id,
isFocused: focusedId === node.id,
@@ -530,11 +540,24 @@ export function SortableTree({
onOpenFull: handleOpenFull,
isLoading: loadingIds.has(node.id),
}),
[selectedPageId, focusedId, multiSelectedIds, ancestorIds, loadingIds, handleToggleExpand, handleSelect],
);
[
selectedPageId,
focusedId,
multiSelectedIds,
ancestorIds,
loadingIds,
handleToggleExpand,
handleSelect,
],
) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div ref={treeRef} role="tree" tabIndex={0} className="flex flex-col h-full border-r w-80 shrink-0">
<div
ref={treeRef}
role="tree"
tabIndex={0}
className="flex flex-col h-full border-r w-80 shrink-0"
>
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
<div className="flex items-center gap-1.5">
@@ -547,10 +570,10 @@ export function SortableTree({
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 pr-7 text-sm"
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchQuery("");
setSearchResults(null);
searchInputRef.current?.blur();
if (e.key === 'Escape') {
setSearchQuery('')
setSearchResults(null)
searchInputRef.current?.blur()
}
}}
/>
@@ -560,8 +583,8 @@ export function SortableTree({
<button
type="button"
onClick={() => {
setSearchQuery("");
setSearchResults(null);
setSearchQuery('')
setSearchResults(null)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -572,10 +595,10 @@ export function SortableTree({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={sortMode ? "default" : "outline"}
variant={sortMode ? 'default' : 'outline'}
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
onClick={() => dispatch({ type: 'TOGGLE_SORT_MODE' })}
>
{sortMode ? (
<GripVertical className="h-3.5 w-3.5" />
@@ -585,7 +608,7 @@ export function SortableTree({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
<p>{sortMode ? 'Sort mode' : 'Edit mode'}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -594,21 +617,21 @@ export function SortableTree({
<div className="flex items-center gap-1.5">
{/* Status filter */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
{(["all", "enabled", "disabled"] as FilterMode[]).map((f) => (
{(['all', 'enabled', 'disabled'] as FilterMode[]).map((f) => (
<button
type="button"
key={f}
onClick={() => setFilter(f)}
className={cn(
"flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors",
'flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
filter === f
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{f === "enabled" && <ToggleRight className="h-2.5 w-2.5" />}
{f === "disabled" && <ToggleLeft className="h-2.5 w-2.5" />}
{f === "all" ? "All" : f === "enabled" ? "On" : "Off"}
{f === 'enabled' && <ToggleRight className="h-2.5 w-2.5" />}
{f === 'disabled' && <ToggleLeft className="h-2.5 w-2.5" />}
{f === 'all' ? 'All' : f === 'enabled' ? 'On' : 'Off'}
</button>
))}
</div>
@@ -658,7 +681,7 @@ export function SortableTree({
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{flatItems.length}
{totalNodes > 0 && flatItems.length !== totalNodes ? ` / ${totalNodes}` : ""} pages
{totalNodes > 0 && flatItems.length !== totalNodes ? ` / ${totalNodes}` : ''} pages
</Badge>
{sortMode && (
<Badge variant="default" className="text-[9px] h-4 px-1">
@@ -677,7 +700,7 @@ export function SortableTree({
) : searchResults !== null ? (
<div className="p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</p>
{flatItems.map((node) => (
<div key={node.id} className="py-px">
@@ -694,22 +717,22 @@ export function SortableTree({
onDragEnd={handleDragEnd}
>
<SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index];
const node = flatItems[vItem.index]
return (
<div
key={node.id}
style={{
position: "absolute",
position: 'absolute',
top: vItem.start,
width: "100%",
width: '100%',
height: vItem.size,
}}
>
<SortableTreeItem {...getItemProps(node)} sortMode={true} />
</div>
);
)
})}
</div>
</SortableContext>
@@ -736,22 +759,22 @@ export function SortableTree({
</DndContext>
) : (
/* Normal mode: virtualized, no dnd-kit overhead */
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index];
const node = flatItems[vItem.index]
return (
<div
key={node.id}
style={{
position: "absolute",
position: 'absolute',
top: vItem.start,
width: "100%",
width: '100%',
height: vItem.size,
}}
>
<TreeItemContent {...getItemProps(node)} sortMode={false} />
</div>
);
)
})}
</div>
)}
@@ -784,19 +807,24 @@ export function SortableTree({
>
<PowerOff className="mr-1 h-3 w-3" /> Disable
</Button>
<Button size="sm" variant="destructive" className="h-6 text-[11px] px-2" onClick={handleBulkDelete}>
<Button
size="sm"
variant="destructive"
className="h-6 text-[11px] px-2"
onClick={handleBulkDelete}
>
<Trash2 className="mr-1 h-3 w-3" /> Delete
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[11px] px-2 ml-auto"
onClick={() => dispatch({ type: "CLEAR_MULTI_SELECT" })}
onClick={() => dispatch({ type: 'CLEAR_MULTI_SELECT' })}
>
Clear
</Button>
</div>
)}
</div>
);
)
}
@@ -1,23 +1,23 @@
"use client";
'use client'
import { FolderTree, Keyboard, Package, ToggleRight } from "lucide-react";
import { useMemo } from "react";
import { useCatalogManager } from "./use-catalog-store";
import { FolderTree, Keyboard, Package, ToggleRight } from 'lucide-react'
import { useMemo } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function StatsBar() {
const { state } = useCatalogManager();
const { state } = useCatalogManager()
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values());
const enabled = nodes.filter((n) => n.enabled === "1").length;
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0);
const nodes = Array.from(state.nodes.values())
const enabled = nodes.filter((n) => n.enabled === '1').length
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0)
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
};
}, [state.nodes, state.rootPages]);
}
}, [state.nodes, state.rootPages])
return (
<div className="flex items-center justify-between gap-4 px-4 py-1.5 border-t bg-muted/30 text-[11px] text-muted-foreground shrink-0">
@@ -46,9 +46,10 @@ export function StatsBar() {
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">/</kbd> search
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+K</kbd> quick add
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+K</kbd> quick
add
</span>
</div>
</div>
);
)
}
@@ -1,7 +1,7 @@
"use client";
'use client'
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
ChevronRight,
Copy as CopyIcon,
@@ -17,10 +17,10 @@ import {
Power,
PowerOff,
Trash2,
} from "lucide-react";
import React from "react";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
} from 'lucide-react'
import React from 'react'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import {
ContextMenu,
ContextMenuContent,
@@ -28,54 +28,54 @@ import {
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { translateCaption } from "@/lib/catalog-translations";
import { cn } from "@/lib/utils";
} from '@/components/ui/context-menu'
import { translateCaption } from '@/lib/catalog-translations'
import { cn } from '@/lib/utils'
export interface FlatTreeNode {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
depth: number;
isExpanded: boolean;
id: number
caption: string
parentId: number
enabled: string
visible: string
iconImage: number
iconColor: number
pageLayout: string
childCount: number
itemCount: number
depth: number
isExpanded: boolean
}
export interface TreeItemProps {
node: FlatTreeNode;
sortMode: boolean;
isSelected: boolean;
isFocused: boolean;
isMultiSelected: boolean;
isAncestorOfSelected?: boolean;
isOverlay?: boolean;
onToggleExpand: (id: number) => void;
onSelect: (id: number, e?: React.MouseEvent) => void;
onAddSubpage: (parentId: number) => void;
onToggleEnabled: (id: number, currentEnabled: string) => void;
onToggleVisible: (id: number, currentVisible: string) => void;
onDuplicate: (id: number) => void;
onDelete: (id: number) => void;
onOpenFull: (id: number) => void;
isLoading?: boolean;
node: FlatTreeNode
sortMode: boolean
isSelected: boolean
isFocused: boolean
isMultiSelected: boolean
isAncestorOfSelected?: boolean
isOverlay?: boolean
onToggleExpand: (id: number) => void
onSelect: (id: number, e?: React.MouseEvent) => void
onAddSubpage: (parentId: number) => void
onToggleEnabled: (id: number, currentEnabled: string) => void
onToggleVisible: (id: number, currentVisible: string) => void
onDuplicate: (id: number) => void
onDelete: (id: number) => void
onOpenFull: (id: number) => void
isLoading?: boolean
}
export function SortableTreeItem(props: TreeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.node.id,
disabled: !props.sortMode,
});
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
}
return (
<div ref={setNodeRef} style={style}>
@@ -85,7 +85,7 @@ export function SortableTreeItem(props: TreeItemProps) {
dragHandleProps={props.sortMode ? { ...attributes, ...listeners } : undefined}
/>
</div>
);
)
}
export const TreeItemContent = React.memo(
@@ -109,13 +109,13 @@ export const TreeItemContent = React.memo(
onOpenFull,
isLoading,
}: TreeItemProps & {
isDragging?: boolean;
dragHandleProps?: Record<string, unknown>;
isDragging?: boolean
dragHandleProps?: Record<string, unknown>
}) {
const isDisabled = node.enabled !== "1";
const isHidden = node.visible !== "1";
const italianName = translateCaption(node.caption);
const layoutColor = LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
const isDisabled = node.enabled !== '1'
const isHidden = node.visible !== '1'
const italianName = translateCaption(node.caption)
const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground'
const content = (
// biome-ignore lint/a11y/useSemanticElements: wrapped by ContextMenuTrigger asChild (Radix); a real <button> would conflict with sortable handle inside
@@ -123,23 +123,26 @@ export const TreeItemContent = React.memo(
role="button"
tabIndex={sortMode ? -1 : 0}
className={cn(
"flex items-center gap-1 rounded-md px-1.5 py-1 group transition-colors",
!sortMode && "cursor-pointer",
isSelected && !isDragging && "bg-primary/10 ring-1 ring-primary/30",
isMultiSelected && !isSelected && !isDragging && "bg-primary/5 ring-1 ring-primary/15",
isFocused && !isSelected && !isMultiSelected && "ring-2 ring-[var(--admin-focus-ring)]/40",
!isSelected && !isMultiSelected && !isFocused && !isDragging && "hover:bg-accent/60",
isAncestorOfSelected && !isSelected && !isMultiSelected && "border-l-2 border-l-primary/30",
isDisabled && "opacity-40",
isDragging && "opacity-30",
isOverlay && "bg-card shadow-lg border opacity-100",
'flex items-center gap-1 rounded-md px-1.5 py-1 group transition-colors',
!sortMode && 'cursor-pointer',
isSelected && !isDragging && 'bg-primary/10 ring-1 ring-primary/30',
isMultiSelected && !isSelected && !isDragging && 'bg-primary/5 ring-1 ring-primary/15',
isFocused && !isSelected && !isMultiSelected && 'ring-2 ring-[var(--admin-focus-ring)]/40',
!isSelected && !isMultiSelected && !isFocused && !isDragging && 'hover:bg-accent/60',
isAncestorOfSelected &&
!isSelected &&
!isMultiSelected &&
'border-l-2 border-l-primary/30',
isDisabled && 'opacity-40',
isDragging && 'opacity-30',
isOverlay && 'bg-card shadow-lg border opacity-100',
)}
style={{ paddingLeft: isOverlay ? 8 : `${node.depth * 20 + 8}px` }}
onClick={(e) => !sortMode && onSelect(node.id, e)}
onKeyDown={(e) => {
if (!sortMode && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onSelect(node.id, e as unknown as React.MouseEvent);
if (!sortMode && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onSelect(node.id, e as unknown as React.MouseEvent)
}
}}
>
@@ -158,8 +161,8 @@ export const TreeItemContent = React.memo(
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
e.stopPropagation()
onToggleExpand(node.id)
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
@@ -168,8 +171,8 @@ export const TreeItemContent = React.memo(
) : (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
node.isExpanded && "rotate-90",
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
node.isExpanded && 'rotate-90',
)}
/>
)}
@@ -206,18 +209,18 @@ export const TreeItemContent = React.memo(
)}
<Badge
className={cn(
"h-4 px-1 text-[8px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0",
'h-4 px-1 text-[8px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
layoutColor,
)}
>
{node.pageLayout.replace("default_3x3", "3x3")}
{node.pageLayout.replace('default_3x3', '3x3')}
</Badge>
</div>
</div>
);
)
// In sort mode or overlay, no context menu
if (sortMode || isOverlay) return content;
if (sortMode || isOverlay) return content
return (
<ContextMenu>
@@ -252,12 +255,20 @@ export const TreeItemContent = React.memo(
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onToggleEnabled(node.id, node.enabled)}>
{isDisabled ? <Power className="mr-2 h-3.5 w-3.5" /> : <PowerOff className="mr-2 h-3.5 w-3.5" />}
{isDisabled ? "Enable" : "Disable"}
{isDisabled ? (
<Power className="mr-2 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-2 h-3.5 w-3.5" />
)}
{isDisabled ? 'Enable' : 'Disable'}
</ContextMenuItem>
<ContextMenuItem onClick={() => onToggleVisible(node.id, node.visible)}>
{isHidden ? <Eye className="mr-2 h-3.5 w-3.5" /> : <EyeOffIcon className="mr-2 h-3.5 w-3.5" />}
{isHidden ? "Show" : "Hide"}
{isHidden ? (
<Eye className="mr-2 h-3.5 w-3.5" />
) : (
<EyeOffIcon className="mr-2 h-3.5 w-3.5" />
)}
{isHidden ? 'Show' : 'Hide'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
@@ -269,7 +280,7 @@ export const TreeItemContent = React.memo(
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
)
},
(prev, next) => {
return (
@@ -287,6 +298,6 @@ export const TreeItemContent = React.memo(
prev.sortMode === next.sortMode &&
prev.isLoading === next.isLoading &&
prev.isDragging === next.isDragging
);
)
},
);
)
@@ -1,34 +1,34 @@
"use client";
'use client'
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from "react";
import type { RootTab, TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from 'react'
import type { RootTab, TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
// ── State ─────────────────────────────────────────────────────
export interface CatalogManagerState {
// Root tabs
rootPages: RootTab[];
activeTabId: number | null;
loadingRoot: boolean;
rootPages: RootTab[]
activeTabId: number | null
loadingRoot: boolean
// Tree
nodes: Map<number, TreeNodeData>;
childrenMap: Map<number, number[]>;
expandedIds: Set<number>;
loadingIds: Set<number>;
selectedPageId: number | null;
multiSelectedIds: Set<number>;
focusedId: number | null;
sortMode: boolean;
searchQuery: string;
searchResults: TreeNodeData[] | null;
searching: boolean;
nodes: Map<number, TreeNodeData>
childrenMap: Map<number, number[]>
expandedIds: Set<number>
loadingIds: Set<number>
selectedPageId: number | null
multiSelectedIds: Set<number>
focusedId: number | null
sortMode: boolean
searchQuery: string
searchResults: TreeNodeData[] | null
searching: boolean
// Editor
editorDirty: boolean;
editorDirty: boolean
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>;
ancestors: Array<{ id: number; caption: string }>
}
const initialState: CatalogManagerState = {
@@ -43,51 +43,51 @@ const initialState: CatalogManagerState = {
multiSelectedIds: new Set(),
focusedId: null,
sortMode: false,
searchQuery: "",
searchQuery: '',
searchResults: null,
searching: false,
editorDirty: false,
ancestors: [],
};
}
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
| { type: "SELECT_PAGE"; id: number | null }
| { type: "TOGGLE_EXPAND"; id: number }
| { type: "SET_EXPANDED"; id: number; expanded: boolean }
| { type: "EXPAND_PATH"; ids: number[] }
| { type: "COLLAPSE_ALL" }
| { type: "SET_NODES"; nodes: TreeNodeData[] }
| { type: "SET_CHILDREN"; parentId: number; children: TreeNodeData[] }
| { type: "UPDATE_NODE"; update: TreeNodeUpdate }
| { type: "REMOVE_NODE"; id: number }
| { type: "SET_LOADING_IDS"; ids: Set<number> }
| { type: "SET_SEARCH_QUERY"; query: string }
| { type: "SET_SEARCH_RESULTS"; results: TreeNodeData[] | null }
| { type: "SET_SEARCHING"; searching: boolean }
| { type: "TOGGLE_SORT_MODE" }
| { type: "TOGGLE_MULTI_SELECT"; id: number }
| { type: "RANGE_SELECT"; from: number; to: number; flatIds: number[] }
| { type: "CLEAR_MULTI_SELECT" }
| { type: "SET_FOCUSED"; id: number | null }
| { type: "SET_DIRTY"; dirty: boolean }
| { type: "SET_ANCESTORS"; ancestors: Array<{ id: number; caption: string }> }
| { type: "ON_DIALOG_CLOSE" }
| { type: "CLEAR_TREE" };
| { type: 'SET_ROOT_PAGES'; pages: RootTab[] }
| { type: 'SET_LOADING_ROOT'; loading: boolean }
| { type: 'SET_ACTIVE_TAB'; id: number | null }
| { type: 'SELECT_PAGE'; id: number | null }
| { type: 'TOGGLE_EXPAND'; id: number }
| { type: 'SET_EXPANDED'; id: number; expanded: boolean }
| { type: 'EXPAND_PATH'; ids: number[] }
| { type: 'COLLAPSE_ALL' }
| { type: 'SET_NODES'; nodes: TreeNodeData[] }
| { type: 'SET_CHILDREN'; parentId: number; children: TreeNodeData[] }
| { type: 'UPDATE_NODE'; update: TreeNodeUpdate }
| { type: 'REMOVE_NODE'; id: number }
| { type: 'SET_LOADING_IDS'; ids: Set<number> }
| { type: 'SET_SEARCH_QUERY'; query: string }
| { type: 'SET_SEARCH_RESULTS'; results: TreeNodeData[] | null }
| { type: 'SET_SEARCHING'; searching: boolean }
| { type: 'TOGGLE_SORT_MODE' }
| { type: 'TOGGLE_MULTI_SELECT'; id: number }
| { type: 'RANGE_SELECT'; from: number; to: number; flatIds: number[] }
| { type: 'CLEAR_MULTI_SELECT' }
| { type: 'SET_FOCUSED'; id: number | null }
| { type: 'SET_DIRTY'; dirty: boolean }
| { type: 'SET_ANCESTORS'; ancestors: Array<{ id: number; caption: string }> }
| { type: 'ON_DIALOG_CLOSE' }
| { type: 'CLEAR_TREE' }
// ── Reducer ───────────────────────────────────────────────────
function reducer(state: CatalogManagerState, action: CatalogAction): CatalogManagerState {
switch (action.type) {
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
case 'SET_ROOT_PAGES':
return { ...state, rootPages: action.pages, loadingRoot: false }
case 'SET_LOADING_ROOT':
return { ...state, loadingRoot: action.loading }
case 'SET_ACTIVE_TAB':
return {
...state,
activeTabId: action.id,
@@ -95,55 +95,55 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
multiSelectedIds: new Set(),
focusedId: null,
ancestors: [],
};
case "SELECT_PAGE":
}
case 'SELECT_PAGE':
return {
...state,
selectedPageId: action.id,
multiSelectedIds: new Set(),
focusedId: action.id,
};
case "TOGGLE_EXPAND": {
const next = new Set(state.expandedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, expandedIds: next };
}
case "SET_EXPANDED": {
const next = new Set(state.expandedIds);
if (action.expanded) next.add(action.id);
else next.delete(action.id);
return { ...state, expandedIds: next };
}
case "EXPAND_PATH": {
const next = new Set(state.expandedIds);
for (const id of action.ids) next.add(id);
return { ...state, expandedIds: next };
}
case "COLLAPSE_ALL":
return { ...state, expandedIds: new Set() };
case "SET_NODES": {
const next = new Map(state.nodes);
for (const n of action.nodes) next.set(n.id, n);
return { ...state, nodes: next };
}
case "SET_CHILDREN": {
const nextNodes = new Map(state.nodes);
const nextChildren = new Map(state.childrenMap);
const childIds: number[] = [];
for (const child of action.children) {
nextNodes.set(child.id, child);
childIds.push(child.id);
}
nextChildren.set(action.parentId, childIds);
return { ...state, nodes: nextNodes, childrenMap: nextChildren };
case 'TOGGLE_EXPAND': {
const next = new Set(state.expandedIds)
if (next.has(action.id)) next.delete(action.id)
else next.add(action.id)
return { ...state, expandedIds: next }
}
case "UPDATE_NODE": {
const existing = state.nodes.get(action.update.id);
if (!existing) return state;
const nextNodes = new Map(state.nodes);
case 'SET_EXPANDED': {
const next = new Set(state.expandedIds)
if (action.expanded) next.add(action.id)
else next.delete(action.id)
return { ...state, expandedIds: next }
}
case 'EXPAND_PATH': {
const next = new Set(state.expandedIds)
for (const id of action.ids) next.add(id)
return { ...state, expandedIds: next }
}
case 'COLLAPSE_ALL':
return { ...state, expandedIds: new Set() }
case 'SET_NODES': {
const next = new Map(state.nodes)
for (const n of action.nodes) next.set(n.id, n)
return { ...state, nodes: next }
}
case 'SET_CHILDREN': {
const nextNodes = new Map(state.nodes)
const nextChildren = new Map(state.childrenMap)
const childIds: number[] = []
for (const child of action.children) {
nextNodes.set(child.id, child)
childIds.push(child.id)
}
nextChildren.set(action.parentId, childIds)
return { ...state, nodes: nextNodes, childrenMap: nextChildren }
}
case 'UPDATE_NODE': {
const existing = state.nodes.get(action.update.id)
if (!existing) return state
const nextNodes = new Map(state.nodes)
nextNodes.set(action.update.id, {
...existing,
...(action.update.caption !== undefined && { caption: action.update.caption }),
@@ -151,77 +151,77 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
...(action.update.visible !== undefined && { visible: action.update.visible }),
...(action.update.pageLayout !== undefined && { pageLayout: action.update.pageLayout }),
...(action.update.iconImage !== undefined && { iconImage: action.update.iconImage }),
});
return { ...state, nodes: nextNodes };
})
return { ...state, nodes: nextNodes }
}
case "REMOVE_NODE": {
const nextNodes = new Map(state.nodes);
nextNodes.delete(action.id);
const nextChildren = new Map(state.childrenMap);
case 'REMOVE_NODE': {
const nextNodes = new Map(state.nodes)
nextNodes.delete(action.id)
const nextChildren = new Map(state.childrenMap)
// Remove from parent's children list
for (const [parentId, children] of nextChildren) {
const idx = children.indexOf(action.id);
const idx = children.indexOf(action.id)
if (idx !== -1) {
nextChildren.set(
parentId,
children.filter((cid) => cid !== action.id),
);
)
}
}
// Remove this node's children entry
nextChildren.delete(action.id);
const nextSelected = state.selectedPageId === action.id ? null : state.selectedPageId;
return { ...state, nodes: nextNodes, childrenMap: nextChildren, selectedPageId: nextSelected };
nextChildren.delete(action.id)
const nextSelected = state.selectedPageId === action.id ? null : state.selectedPageId
return { ...state, nodes: nextNodes, childrenMap: nextChildren, selectedPageId: nextSelected }
}
case "SET_LOADING_IDS":
return { ...state, loadingIds: action.ids };
case 'SET_LOADING_IDS':
return { ...state, loadingIds: action.ids }
case "SET_SEARCH_QUERY":
return { ...state, searchQuery: action.query };
case "SET_SEARCH_RESULTS":
return { ...state, searchResults: action.results, searching: false };
case "SET_SEARCHING":
return { ...state, searching: action.searching };
case "TOGGLE_SORT_MODE":
return { ...state, sortMode: !state.sortMode, multiSelectedIds: new Set() };
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.query }
case 'SET_SEARCH_RESULTS':
return { ...state, searchResults: action.results, searching: false }
case 'SET_SEARCHING':
return { ...state, searching: action.searching }
case 'TOGGLE_SORT_MODE':
return { ...state, sortMode: !state.sortMode, multiSelectedIds: new Set() }
case "TOGGLE_MULTI_SELECT": {
const next = new Set(state.multiSelectedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return { ...state, multiSelectedIds: next, focusedId: action.id };
case 'TOGGLE_MULTI_SELECT': {
const next = new Set(state.multiSelectedIds)
if (next.has(action.id)) next.delete(action.id)
else next.add(action.id)
return { ...state, multiSelectedIds: next, focusedId: action.id }
}
case "RANGE_SELECT": {
const { flatIds, from, to } = action;
const fromIdx = flatIds.indexOf(from);
const toIdx = flatIds.indexOf(to);
if (fromIdx === -1 || toIdx === -1) return state;
const lo = Math.min(fromIdx, toIdx);
const hi = Math.max(fromIdx, toIdx);
const next = new Set(state.multiSelectedIds);
for (let i = lo; i <= hi; i++) next.add(flatIds[i]);
return { ...state, multiSelectedIds: next, focusedId: action.to };
case 'RANGE_SELECT': {
const { flatIds, from, to } = action
const fromIdx = flatIds.indexOf(from)
const toIdx = flatIds.indexOf(to)
if (fromIdx === -1 || toIdx === -1) return state
const lo = Math.min(fromIdx, toIdx)
const hi = Math.max(fromIdx, toIdx)
const next = new Set(state.multiSelectedIds)
for (let i = lo; i <= hi; i++) next.add(flatIds[i])
return { ...state, multiSelectedIds: next, focusedId: action.to }
}
case "CLEAR_MULTI_SELECT":
return { ...state, multiSelectedIds: new Set() };
case "SET_FOCUSED":
return { ...state, focusedId: action.id };
case 'CLEAR_MULTI_SELECT':
return { ...state, multiSelectedIds: new Set() }
case 'SET_FOCUSED':
return { ...state, focusedId: action.id }
case "SET_DIRTY":
return { ...state, editorDirty: action.dirty };
case "SET_ANCESTORS":
return { ...state, ancestors: action.ancestors };
case 'SET_DIRTY':
return { ...state, editorDirty: action.dirty }
case 'SET_ANCESTORS':
return { ...state, ancestors: action.ancestors }
case "ON_DIALOG_CLOSE":
case 'ON_DIALOG_CLOSE':
return {
...state,
selectedPageId: null,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
}
case "CLEAR_TREE":
case 'CLEAR_TREE':
return {
...state,
nodes: new Map(),
@@ -231,34 +231,36 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
searchQuery: "",
searchQuery: '',
searchResults: null,
ancestors: [],
};
}
default:
return state;
return state
}
}
// ── Context ───────────────────────────────────────────────────
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
state: CatalogManagerState
dispatch: Dispatch<CatalogAction>
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null);
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null)
export function CatalogManagerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
const [state, dispatch] = useReducer(reducer, initialState)
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>{children}</CatalogManagerContext.Provider>
);
<CatalogManagerContext.Provider value={{ state, dispatch }}>
{children}
</CatalogManagerContext.Provider>
)
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext);
if (!ctx) throw new Error("useCatalogManager must be used within CatalogManagerProvider");
return ctx;
const ctx = useContext(CatalogManagerContext)
if (!ctx) throw new Error('useCatalogManager must be used within CatalogManagerProvider')
return ctx
}