fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPageId || selectedPageId <= 0) {
|
||||
dispatch({ type: 'SET_ANCESTORS', ancestors: [] })
|
||||
return
|
||||
}
|
||||
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 ?? [] })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => ac.abort()
|
||||
}, [selectedPageId, dispatch])
|
||||
|
||||
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">
|
||||
<Home className="h-3 w-3 shrink-0" />
|
||||
{ancestors.map((a) => (
|
||||
<Fragment key={a.id}>
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'SELECT_PAGE', id: a.id })}
|
||||
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
|
||||
>
|
||||
{a.caption}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Current page */}
|
||||
{(() => {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
'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'
|
||||
|
||||
export function CatalogManagerDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Visual Manager
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<CatalogManagerProvider>
|
||||
<CatalogManagerInner open={open} onOpenChange={setOpen} />
|
||||
</CatalogManagerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CatalogManagerInner({
|
||||
open,
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
}) {
|
||||
const { state, dispatch } = useCatalogManager()
|
||||
|
||||
// Load root pages when dialog opens
|
||||
const loadRootPages = useCallback(async () => {
|
||||
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 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 })
|
||||
|
||||
// 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 })
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load catalog root pages')
|
||||
dispatch({ type: 'SET_LOADING_ROOT', loading: false })
|
||||
}
|
||||
}, [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
|
||||
|
||||
// Handle dialog close: preserve tree state, clear selection
|
||||
useEffect(() => {
|
||||
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' })
|
||||
}
|
||||
|
||||
const handleCreateRoot = async () => {
|
||||
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')
|
||||
} catch {
|
||||
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 })
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
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 })
|
||||
|
||||
// Update root tabs if root page
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Visual Catalog Manager
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<BreadcrumbBar />
|
||||
|
||||
{/* Root tabs */}
|
||||
<RootTabs
|
||||
tabs={state.rootPages}
|
||||
activeTabId={state.activeTabId}
|
||||
onTabClick={handleTabClick}
|
||||
onEditTab={handleEditTab}
|
||||
onCreateRoot={handleCreateRoot}
|
||||
loading={state.loadingRoot}
|
||||
/>
|
||||
|
||||
{/* Main content: tree + editor */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<SortableTree
|
||||
activeTabId={state.activeTabId}
|
||||
selectedPageId={state.selectedPageId}
|
||||
onSelectPage={handleSelectPage}
|
||||
onTreeMutation={loadRootPages}
|
||||
nodeUpdate={null}
|
||||
/>
|
||||
<InlineEditor pageId={state.selectedPageId} onSaved={handleEditorSaved} />
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<StatsBar />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { ImageOff, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
value: string
|
||||
basePath?: string
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
value,
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.trim()) {
|
||||
setSrc('')
|
||||
setStatus('idle')
|
||||
setFallback(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setFallback(false)
|
||||
const url = `${basePath}/${value.trim()}.png`
|
||||
setSrc(url)
|
||||
setStatus('loading')
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
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')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'idle' || !src) {
|
||||
return (
|
||||
<div
|
||||
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 || ''}`}
|
||||
style={{ height }}
|
||||
>
|
||||
{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')}
|
||||
onError={handleError}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Languages,
|
||||
Loader2,
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 }[]
|
||||
}
|
||||
|
||||
export interface PageUpdateData {
|
||||
id: number
|
||||
caption: string
|
||||
enabled: string
|
||||
visible: string
|
||||
pageLayout: string
|
||||
iconImage: number
|
||||
}
|
||||
|
||||
interface InlineEditorProps {
|
||||
pageId: number | null
|
||||
onSaved: (updated: PageUpdateData) => void
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Items/translate data (lazy loaded)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadPage = useCallback(async (id: number) => {
|
||||
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 formData: FormState = {
|
||||
caption: p.caption,
|
||||
pageLayout: p.pageLayout,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
minRank: p.minRank,
|
||||
clubOnly: String(p.clubOnly),
|
||||
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)
|
||||
} catch {
|
||||
toast.error('Failed to load page')
|
||||
setPage(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadItemsData = useCallback(async (id: number) => {
|
||||
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)
|
||||
} catch {
|
||||
toast.error('Failed to load items')
|
||||
} finally {
|
||||
setItemsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reloadItems = useCallback(() => {
|
||||
if (pageId && pageId > 0) loadItemsData(pageId)
|
||||
}, [pageId, loadItemsData])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageId && pageId > 0) {
|
||||
loadPage(pageId)
|
||||
setItemsData(null)
|
||||
setItemsLoaded(false)
|
||||
setActiveTab('settings')
|
||||
setShowPreview(false)
|
||||
} else {
|
||||
setPage(null)
|
||||
setForm({})
|
||||
setOriginal({})
|
||||
setItemsData(null)
|
||||
setItemsLoaded(false)
|
||||
}
|
||||
}, [pageId, loadPage])
|
||||
|
||||
// Lazy-load items
|
||||
useEffect(() => {
|
||||
if (
|
||||
(activeTab === 'items' || activeTab === 'translate') &&
|
||||
!itemsLoaded &&
|
||||
!itemsLoading &&
|
||||
pageId &&
|
||||
pageId > 0
|
||||
) {
|
||||
loadItemsData(pageId)
|
||||
}
|
||||
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData])
|
||||
|
||||
function update(key: string, value: string | number | null) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
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 })
|
||||
setPage((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
caption: String(form.caption || prev.caption),
|
||||
enabled: String(form.enabled ?? prev.enabled),
|
||||
visible: String(form.visible ?? prev.visible),
|
||||
pageLayout: String(form.pageLayout || prev.pageLayout),
|
||||
iconImage: (form.iconImage as number) ?? prev.iconImage,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
onSaved({
|
||||
id: page.id,
|
||||
caption: String(form.caption || page.caption),
|
||||
enabled: String(form.enabled ?? page.enabled),
|
||||
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')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageId || pageId <= 0) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<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
|
||||
|
||||
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogIcon iconImage={(form.iconImage as number) ?? page.iconImage} size={24} />
|
||||
<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)}>
|
||||
{form.pageLayout as string}
|
||||
</Badge>
|
||||
{isDirty && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1.5 text-[9px] text-orange-600 border-orange-300 bg-orange-50 dark:bg-orange-950/30 dark:border-orange-700"
|
||||
>
|
||||
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
|
||||
unsaved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{page.id} · Parent: {page.parentId === -1 ? 'Root' : `#${page.parentId}`}
|
||||
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/admin/catalog/${page.id}`} target="_blank">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Full editor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Dirty banner */}
|
||||
{isDirty && (
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-yellow-50 dark:bg-yellow-950/30 border-b border-yellow-200 dark:border-yellow-800 text-xs text-yellow-800 dark:text-yellow-200 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
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="mx-4 mt-2 shrink-0">
|
||||
<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-orange-500" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-1.5 text-xs">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Items
|
||||
{itemsData && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
|
||||
{itemsData.items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-1.5 text-xs">
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
Translate
|
||||
{itemsData &&
|
||||
(() => {
|
||||
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-orange-500 text-white text-[9px] px-1 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Settings tab ──────────────────────────────── */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
<div className="space-y-4">
|
||||
{/* Caption + Layout */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Caption</Label>
|
||||
<Input
|
||||
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)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((l) => {
|
||||
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)} />
|
||||
{l}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? 'Custom layout'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order + Rank + Icon */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNum as number}
|
||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Icon</Label>
|
||||
<IconPicker
|
||||
value={(form.iconImage as number) ?? 0}
|
||||
onChange={(v) => update('iconImage', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<ToggleRow
|
||||
id="ie-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
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')}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === '1'}
|
||||
onChange={(v) => update('clubOnly', v ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headline image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageHeadline || '')}
|
||||
onChange={(v) => update('pageHeadline', v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
/>
|
||||
<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)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
/>
|
||||
<ImagePreview value={String(form.pageTeaser || '')} height={50} />
|
||||
</div>
|
||||
|
||||
{/* Collapsible text fields */}
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
)}
|
||||
Text fields
|
||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||
has content
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showTexts && (
|
||||
<div className="space-y-3 pl-2 border-l-2 border-border/40">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 1</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText1 || '')}
|
||||
onChange={(e) => update('pageText1', e.target.value || null)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 2</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText2 || '')}
|
||||
onChange={(e) => update('pageText2', e.target.value || null)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text Details</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
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)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSave} disabled={saving || !isDirty} className="w-full">
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Items tab ─────────────────────────────────── */}
|
||||
<TabsContent value="items" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
{itemsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<QuickAddFurni pageId={page.id} furniRevisionMap={itemsData.furniRevisionMap} />
|
||||
<BulkImportItems pageId={page.id} onImported={reloadItems} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{showPreview ? 'Hide Preview' : 'Shop Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Shop preview (togglable) */}
|
||||
{showPreview && (
|
||||
<ItemsShopPreview
|
||||
items={itemsData.items}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
layout={page.pageLayout}
|
||||
canEdit={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Items table */}
|
||||
<CatalogItemsTable
|
||||
items={itemsData.items}
|
||||
pageId={page.id}
|
||||
pageLayout={page.pageLayout}
|
||||
canEdit={true}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Translate tab ─────────────────────────────── */}
|
||||
<TabsContent value="translate" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
{itemsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<CatalogTranslateTab
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
canEdit={true}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle row (inline) ─────────────────────────────────────
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={id} className="text-xs font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
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'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
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 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')
|
||||
} catch {
|
||||
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'}`)
|
||||
} catch {
|
||||
toast.error('Failed to toggle')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (tab: RootTab) => {
|
||||
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`)
|
||||
} catch {
|
||||
toast.error('Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab) => (
|
||||
<SortableRootTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={activeTabId === tab.id}
|
||||
onClick={() => onTabClick(tab.id)}
|
||||
onEdit={() => onEditTab(tab.id)}
|
||||
onToggle={() => handleToggle(tab)}
|
||||
onDelete={() => handleDelete(tab)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{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}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>New root page</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sortable root tab ─────────────────────────────────────────
|
||||
|
||||
function SortableRootTab({
|
||||
tab,
|
||||
isActive,
|
||||
onClick,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
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>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<TabButton tab={tab} isActive={isActive} onClick={onClick} onDoubleClick={onEdit} />
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit page
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onToggle}>
|
||||
{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'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab button render ─────────────────────────────────────────
|
||||
|
||||
function TabButton({
|
||||
tab,
|
||||
isActive,
|
||||
isOverlay,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
tab: RootTab
|
||||
isActive: boolean
|
||||
isOverlay?: boolean
|
||||
onClick?: () => void
|
||||
onDoubleClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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',
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<CatalogIcon iconImage={tab.iconImage} size={16} />
|
||||
<span className="max-w-32 truncate">{tab.caption}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
MousePointer,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
Search,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Trash2,
|
||||
X,
|
||||
} 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'
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
type FilterMode = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
/* ─── Component ──────────────────────────────────────────── */
|
||||
|
||||
export function SortableTree({
|
||||
activeTabId,
|
||||
selectedPageId,
|
||||
onSelectPage,
|
||||
onTreeMutation,
|
||||
nodeUpdate,
|
||||
}: SortableTreeProps) {
|
||||
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 sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
|
||||
// ── Full tree load (single API call) ────────────────────────
|
||||
const loadFullTree = useCallback(async () => {
|
||||
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 || []
|
||||
|
||||
// Build nodes map and children map in one pass
|
||||
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)
|
||||
}
|
||||
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 })
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load catalog tree')
|
||||
} finally {
|
||||
setTreeLoading(false)
|
||||
}
|
||||
}, [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
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.pages || [])
|
||||
}
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
return () => {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
// Apply external node updates
|
||||
useEffect(() => {
|
||||
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 })
|
||||
} else {
|
||||
dispatch({ type: 'SET_EXPANDED', id, expanded: true })
|
||||
}
|
||||
},
|
||||
[expandedIds, dispatch],
|
||||
)
|
||||
|
||||
// ── Flatten visible tree with filter ────────────────────────
|
||||
const flatItems = useMemo((): FlatTreeNode[] => {
|
||||
if (activeTabId === null) return []
|
||||
if (searchResults) {
|
||||
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }))
|
||||
}
|
||||
const result: FlatTreeNode[] = []
|
||||
const traverse = (parentId: number, depth: number) => {
|
||||
const childIds = childrenMap.get(parentId) || []
|
||||
for (const cid of childIds) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
traverse(activeTabId, 0)
|
||||
return result
|
||||
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter])
|
||||
|
||||
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)
|
||||
while (current && current.parentId > 0) {
|
||||
set.add(current.parentId)
|
||||
current = nodes.get(current.parentId)
|
||||
}
|
||||
return set
|
||||
}, [selectedPageId, nodes])
|
||||
|
||||
// ── Virtualizer ─────────────────────────────────────────────
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatItems.length,
|
||||
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 (idx >= 0) {
|
||||
// Delay slightly to let virtualizer settle
|
||||
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: 'center' }))
|
||||
}
|
||||
}, [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()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === '/') {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('')
|
||||
setSearchResults(null)
|
||||
} else dispatch({ type: 'CLEAR_MULTI_SELECT' })
|
||||
return
|
||||
}
|
||||
|
||||
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 (node && node.childCount > 0 && !node.isExpanded) {
|
||||
handleToggleExpand(focusedId)
|
||||
} else if (node?.isExpanded) {
|
||||
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)
|
||||
if (node?.isExpanded) {
|
||||
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 })
|
||||
}
|
||||
} 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)
|
||||
}, [
|
||||
focusedId,
|
||||
flatIds,
|
||||
flatItems,
|
||||
searchQuery,
|
||||
childrenMap,
|
||||
nodes,
|
||||
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 })
|
||||
} else if (e?.shiftKey && focusedId) {
|
||||
dispatch({ type: 'RANGE_SELECT', from: focusedId, to: id, flatIds })
|
||||
} else {
|
||||
onSelectPage(id)
|
||||
}
|
||||
},
|
||||
[focusedId, flatIds, dispatch, onSelectPage],
|
||||
)
|
||||
|
||||
// ── DnD handlers ────────────────────────────────────────────
|
||||
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 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)
|
||||
dispatch({
|
||||
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()
|
||||
} catch {
|
||||
toast.error('Failed to reorder')
|
||||
loadFullTree()
|
||||
}
|
||||
} else {
|
||||
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')
|
||||
} catch {
|
||||
toast.error('Failed to move page')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────
|
||||
const handleAddSubpage = async (parentId: number) => {
|
||||
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()
|
||||
} catch {
|
||||
toast.error('Failed to create subpage')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
|
||||
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()
|
||||
} catch {
|
||||
toast.error('Failed to toggle enabled')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleVisible = async (id: number, currentVisible: string) => {
|
||||
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()
|
||||
} catch {
|
||||
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
|
||||
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()
|
||||
} catch {
|
||||
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 result = await createCatalogPage({
|
||||
parentId: page.parentId ?? -1,
|
||||
caption: `${page.caption} (copy)`,
|
||||
pageLayout: page.pageLayout ?? 'default_3x3',
|
||||
iconImage: page.iconImage ?? 0,
|
||||
iconColor: page.iconColor ?? 0,
|
||||
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
|
||||
await updateCatalogPage({
|
||||
id: newId,
|
||||
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()
|
||||
} catch {
|
||||
toast.error('Failed to duplicate page')
|
||||
}
|
||||
}
|
||||
|
||||
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 handleCollapseAll = useCallback(() => {
|
||||
dispatch({ type: 'COLLAPSE_ALL' })
|
||||
}, [dispatch])
|
||||
|
||||
// ── Bulk actions ────────────────────────────────────────────
|
||||
const handleBulkEnable = async (enable: boolean) => {
|
||||
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' })
|
||||
}),
|
||||
)
|
||||
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 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,
|
||||
isSelected: selectedPageId === node.id,
|
||||
isFocused: focusedId === node.id,
|
||||
isMultiSelected: multiSelectedIds.has(node.id),
|
||||
isAncestorOfSelected: ancestorIds.has(node.id),
|
||||
onToggleExpand: handleToggleExpand,
|
||||
onSelect: handleSelect,
|
||||
onAddSubpage: handleAddSubpage,
|
||||
onToggleEnabled: handleToggleEnabled,
|
||||
onToggleVisible: handleToggleVisible,
|
||||
onDuplicate: handleDuplicate,
|
||||
onDelete: handleDelete,
|
||||
onOpenFull: handleOpenFull,
|
||||
isLoading: loadingIds.has(node.id),
|
||||
}),
|
||||
[
|
||||
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"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b px-3 py-2 space-y-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search... /"
|
||||
value={searchQuery}
|
||||
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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searching ? (
|
||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSearchResults(null)
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={sortMode ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => dispatch({ type: 'TOGGLE_SORT_MODE' })}
|
||||
>
|
||||
{sortMode ? (
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MousePointer className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{sortMode ? 'Sort mode' : 'Edit mode'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Filters + expand/collapse */}
|
||||
<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) => (
|
||||
<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',
|
||||
filter === f
|
||||
? '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'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleExpandAll}>
|
||||
<ChevronsUpDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Expand all</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCollapseAll}>
|
||||
<ChevronsDownUp className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Collapse all</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{activeTabId && !searchResults && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleAddSubpage(activeTabId)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>New subpage</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Counter + sort badge */}
|
||||
<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
|
||||
</Badge>
|
||||
{sortMode && (
|
||||
<Badge variant="default" className="text-[9px] h-4 px-1">
|
||||
Sort mode
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree body (virtualized) */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{treeLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : searchResults !== null ? (
|
||||
<div className="p-1">
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">
|
||||
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{flatItems.map((node) => (
|
||||
<div key={node.id} className="py-px">
|
||||
<TreeItemContent {...getItemProps(node)} sortMode={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sortMode ? (
|
||||
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const node = flatItems[vItem.index]
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: vItem.start,
|
||||
width: '100%',
|
||||
height: vItem.size,
|
||||
}}
|
||||
>
|
||||
<SortableTreeItem {...getItemProps(node)} sortMode={true} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeNode && (
|
||||
<TreeItemContent
|
||||
node={{ ...activeNode, depth: 0 }}
|
||||
sortMode={true}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
isMultiSelected={false}
|
||||
isOverlay
|
||||
onToggleExpand={noop}
|
||||
onSelect={noop}
|
||||
onAddSubpage={noop}
|
||||
onToggleEnabled={noop}
|
||||
onToggleVisible={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenFull={noop}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
/* Normal mode: virtualized, no dnd-kit overhead */
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const node = flatItems[vItem.index]
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: vItem.start,
|
||||
width: '100%',
|
||||
height: vItem.size,
|
||||
}}
|
||||
>
|
||||
<TreeItemContent {...getItemProps(node)} sortMode={false} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{flatItems.length === 0 && !treeLoading && !searchResults && activeTabId && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No subpages. Click + to create one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating multi-select action bar */}
|
||||
{multiSelectedIds.size > 0 && (
|
||||
<div className="border-t bg-muted/50 px-3 py-2 flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{multiSelectedIds.size} selected
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2"
|
||||
onClick={() => handleBulkEnable(true)}
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" /> Enable
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2"
|
||||
onClick={() => handleBulkEnable(false)}
|
||||
>
|
||||
<PowerOff className="mr-1 h-3 w-3" /> Disable
|
||||
</Button>
|
||||
<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' })}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
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 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)
|
||||
return {
|
||||
rootTabs: state.rootPages.length,
|
||||
totalPages: nodes.length,
|
||||
enabled,
|
||||
totalItems,
|
||||
}
|
||||
}, [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">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderTree className="h-3 w-3" />
|
||||
{stats.rootTabs} root
|
||||
</span>
|
||||
<span>{stats.totalPages} pages</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ToggleRight className="h-3 w-3" />
|
||||
{stats.enabled} enabled
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
{stats.totalItems} items
|
||||
</span>
|
||||
{state.multiSelectedIds.size > 0 && (
|
||||
<span className="text-primary font-medium">{state.multiSelectedIds.size} selected</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span>
|
||||
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+S</kbd> save
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy as CopyIcon,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
EyeOff as EyeOffIcon,
|
||||
Files,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
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'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}>
|
||||
<TreeItemContent
|
||||
{...props}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={props.sortMode ? { ...attributes, ...listeners } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TreeItemContent = React.memo(
|
||||
function TreeItemContent({
|
||||
node,
|
||||
sortMode,
|
||||
isSelected,
|
||||
isFocused,
|
||||
isMultiSelected,
|
||||
isAncestorOfSelected,
|
||||
isOverlay,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onAddSubpage,
|
||||
onToggleEnabled,
|
||||
onToggleVisible,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onOpenFull,
|
||||
isLoading,
|
||||
}: TreeItemProps & {
|
||||
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 content = (
|
||||
// biome-ignore lint/a11y/useSemanticElements: wrapped by ContextMenuTrigger asChild (Radix); a real <button> would conflict with sortable handle inside
|
||||
<div
|
||||
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-blue-500/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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Sort mode grip */}
|
||||
{sortMode && (
|
||||
<div
|
||||
className="flex items-center justify-center h-5 w-5 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse */}
|
||||
{node.childCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpand(node.id)
|
||||
}}
|
||||
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
|
||||
node.isExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<CatalogIcon iconImage={node.iconImage} size={18} />
|
||||
|
||||
{/* Caption */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{italianName ?? node.caption}</span>
|
||||
{italianName && (
|
||||
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
|
||||
{node.caption}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{isHidden && <EyeOff className="h-3 w-3 text-muted-foreground" />}
|
||||
{isDisabled && (
|
||||
<Badge variant="destructive" className="h-4 px-1 text-[9px]">
|
||||
OFF
|
||||
</Badge>
|
||||
)}
|
||||
{node.itemCount > 0 && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px] font-mono">
|
||||
{node.itemCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
className={cn(
|
||||
'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')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// In sort mode or overlay, no context menu
|
||||
if (sortMode || isOverlay) return content
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuLabel className="flex items-center gap-2">
|
||||
<CatalogIcon iconImage={node.iconImage} size={14} />
|
||||
{node.caption}
|
||||
<span className="text-muted-foreground font-normal ml-auto">#{node.id}</span>
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSelect(node.id)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
Open full editor
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => navigator.clipboard.writeText(String(node.id))}>
|
||||
<CopyIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Copy ID
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onAddSubpage(node.id)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add subpage
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicate(node.id)}>
|
||||
<Files className="mr-2 h-3.5 w-3.5" />
|
||||
Duplicate page
|
||||
</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'}
|
||||
</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'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.node.id === next.node.id &&
|
||||
prev.node.caption === next.node.caption &&
|
||||
prev.node.enabled === next.node.enabled &&
|
||||
prev.node.visible === next.node.visible &&
|
||||
prev.node.itemCount === next.node.itemCount &&
|
||||
prev.node.isExpanded === next.node.isExpanded &&
|
||||
prev.node.depth === next.node.depth &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.isFocused === next.isFocused &&
|
||||
prev.isMultiSelected === next.isMultiSelected &&
|
||||
prev.isAncestorOfSelected === next.isAncestorOfSelected &&
|
||||
prev.sortMode === next.sortMode &&
|
||||
prev.isLoading === next.isLoading &&
|
||||
prev.isDragging === next.isDragging
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// Editor
|
||||
editorDirty: boolean
|
||||
|
||||
// Breadcrumb
|
||||
ancestors: Array<{ id: number; caption: string }>
|
||||
}
|
||||
|
||||
const initialState: CatalogManagerState = {
|
||||
rootPages: [],
|
||||
activeTabId: null,
|
||||
loadingRoot: false,
|
||||
nodes: new Map(),
|
||||
childrenMap: new Map(),
|
||||
expandedIds: new Set(),
|
||||
loadingIds: new Set(),
|
||||
selectedPageId: null,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: null,
|
||||
sortMode: false,
|
||||
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' }
|
||||
|
||||
// ── 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':
|
||||
return {
|
||||
...state,
|
||||
activeTabId: action.id,
|
||||
selectedPageId: null,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: null,
|
||||
ancestors: [],
|
||||
}
|
||||
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 '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 }),
|
||||
...(action.update.enabled !== undefined && { enabled: action.update.enabled }),
|
||||
...(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 }
|
||||
}
|
||||
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)
|
||||
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 }
|
||||
}
|
||||
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 '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 '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 'ON_DIALOG_CLOSE':
|
||||
return {
|
||||
...state,
|
||||
selectedPageId: null,
|
||||
editorDirty: false,
|
||||
multiSelectedIds: new Set(),
|
||||
ancestors: [],
|
||||
}
|
||||
|
||||
case 'CLEAR_TREE':
|
||||
return {
|
||||
...state,
|
||||
nodes: new Map(),
|
||||
childrenMap: new Map(),
|
||||
expandedIds: new Set(),
|
||||
loadingIds: new Set(),
|
||||
selectedPageId: null,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: null,
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
ancestors: [],
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────
|
||||
|
||||
interface CatalogManagerContextValue {
|
||||
state: CatalogManagerState
|
||||
dispatch: Dispatch<CatalogAction>
|
||||
}
|
||||
|
||||
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null)
|
||||
|
||||
export function CatalogManagerProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
return (
|
||||
<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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user