Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
834 lines
30 KiB
TypeScript
834 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
ChevronRight,
|
|
ChevronsDownUp,
|
|
ChevronsUpDown,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
EyeOff as EyeOffIcon,
|
|
FolderTree,
|
|
Loader2,
|
|
Package,
|
|
Pencil,
|
|
Plus,
|
|
Power,
|
|
PowerOff,
|
|
Search,
|
|
ToggleLeft,
|
|
ToggleRight,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuLabel,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { translateCaption } from '@/lib/catalog-translations'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
/* ─── Types ──────────────────────────────────────────────── */
|
|
|
|
interface TreeNode {
|
|
id: number
|
|
caption: string
|
|
parentId: number
|
|
enabled: string
|
|
visible: string
|
|
iconImage: number
|
|
iconColor: number
|
|
pageLayout: string
|
|
childCount: number
|
|
itemCount: number
|
|
}
|
|
|
|
interface CatalogTreeProps {
|
|
rootPages: TreeNode[]
|
|
catalogType?: 'normal' | 'bc'
|
|
}
|
|
|
|
/* ─── Context for expand/collapse all ────────────────────── */
|
|
|
|
interface TreeContext {
|
|
expandSignal: number
|
|
collapseSignal: number
|
|
filterMode: FilterMode
|
|
onToggleEnabled: (node: TreeNode) => void
|
|
onToggleVisible: (node: TreeNode) => void
|
|
onDeletePage: (node: TreeNode) => void
|
|
searchQuery: string
|
|
catalogType: 'normal' | 'bc'
|
|
}
|
|
|
|
const TreeCtx = createContext<TreeContext>({
|
|
expandSignal: 0,
|
|
collapseSignal: 0,
|
|
filterMode: 'all',
|
|
onToggleEnabled: () => {},
|
|
onToggleVisible: () => {},
|
|
onDeletePage: () => {},
|
|
searchQuery: '',
|
|
catalogType: 'normal',
|
|
})
|
|
|
|
/* ─── Catalog Icon ───────────────────────────────────────── */
|
|
|
|
export function CatalogIcon({ iconImage, size = 20 }: { iconImage: number; size?: number }) {
|
|
const [error, setError] = useState(false)
|
|
|
|
if (error || iconImage <= 0) {
|
|
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
|
|
alt=""
|
|
width={size}
|
|
height={size}
|
|
className="shrink-0 object-contain"
|
|
style={{ imageRendering: 'pixelated', minWidth: size, minHeight: size }}
|
|
onError={() => setError(true)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/* ─── Layout label helper ────────────────────────────────── */
|
|
|
|
export const LAYOUT_COLORS: Record<string, string> = {
|
|
default_3x3: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
|
|
frontpage: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]',
|
|
spaces_new: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]',
|
|
pets: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
|
|
pets2: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
|
|
pets3: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
|
|
marketplace: 'bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]',
|
|
recycler: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]',
|
|
club_buy: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]',
|
|
single_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
|
|
room_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
|
|
}
|
|
|
|
/* ─── Highlight matched text ─────────────────────────────── */
|
|
|
|
function HighlightText({ text, query }: { text: string; query: string }) {
|
|
if (!query.trim()) return <>{text}</>
|
|
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
|
if (idx === -1) return <>{text}</>
|
|
return (
|
|
<>
|
|
{text.slice(0, idx)}
|
|
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
|
|
{text.slice(idx, idx + query.length)}
|
|
</mark>
|
|
{text.slice(idx + query.length)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/* ─── Main Tree Component ────────────────────────────────── */
|
|
|
|
type FilterMode = 'all' | 'enabled' | 'disabled'
|
|
|
|
export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreeProps) {
|
|
const router = useRouter()
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null)
|
|
const [searching, setSearching] = useState(false)
|
|
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const [expandSignal, setExpandSignal] = useState(0)
|
|
const [collapseSignal, setCollapseSignal] = useState(0)
|
|
const [filterMode, setFilterMode] = useState<FilterMode>('all')
|
|
|
|
// "/" keyboard shortcut to focus search
|
|
useEffect(() => {
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
const tag = (e.target as HTMLElement)?.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
|
e.preventDefault()
|
|
searchInputRef.current?.focus()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', onKeyDown)
|
|
return () => document.removeEventListener('keydown', onKeyDown)
|
|
}, [])
|
|
|
|
// Debounced search
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
useEffect(() => {
|
|
if (!searchQuery.trim()) {
|
|
setSearchResults(null)
|
|
return
|
|
}
|
|
|
|
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
|
searchTimeout.current = setTimeout(async () => {
|
|
setSearching(true)
|
|
try {
|
|
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
|
|
const res = await fetch(
|
|
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`,
|
|
)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSearchResults(data.pages || [])
|
|
}
|
|
} catch (error) {
|
|
console.error('[CatalogTree] Search failed:', error)
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
}, 300)
|
|
|
|
return () => {
|
|
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
|
}
|
|
}, [searchQuery])
|
|
|
|
// Quick actions from context menu
|
|
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
|
|
const catBody = catalogType === 'bc' ? ', "catalog": "bc"' : ''
|
|
|
|
const handleToggleEnabled = useCallback(
|
|
async (node: TreeNode) => {
|
|
try {
|
|
const res = await fetch('/api/admin/catalog/tree', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`,
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
toast.success(`${node.caption} ${node.enabled === '1' ? 'disabled' : 'enabled'}`)
|
|
router.refresh()
|
|
} catch {
|
|
toast.error('Failed to toggle. Open the page to edit.')
|
|
}
|
|
},
|
|
[router, catBody],
|
|
)
|
|
|
|
const handleToggleVisible = useCallback(
|
|
async (node: TreeNode) => {
|
|
try {
|
|
const res = await fetch('/api/admin/catalog/tree', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`,
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
toast.success(`${node.caption} ${node.visible === '1' ? 'hidden' : 'visible'}`)
|
|
router.refresh()
|
|
} catch {
|
|
toast.error('Failed to toggle. Open the page to edit.')
|
|
}
|
|
},
|
|
[router, catBody],
|
|
)
|
|
|
|
const handleDeletePage = useCallback(
|
|
async (node: TreeNode) => {
|
|
const childInfo =
|
|
node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to parent.` : ''
|
|
const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : ''
|
|
if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`,
|
|
{ method: 'DELETE' },
|
|
)
|
|
if (!res.ok) throw new Error()
|
|
toast.success(`"${node.caption}" deleted`)
|
|
router.refresh()
|
|
} catch {
|
|
toast.error('Failed to delete page.')
|
|
}
|
|
},
|
|
[router, catParam],
|
|
)
|
|
|
|
const unfilteredPages = searchResults ?? rootPages
|
|
const displayPages =
|
|
filterMode === 'all'
|
|
? unfilteredPages
|
|
: unfilteredPages.filter((p) =>
|
|
filterMode === 'enabled' ? p.enabled === '1' : p.enabled !== '1',
|
|
)
|
|
|
|
return (
|
|
<TreeCtx.Provider
|
|
value={{
|
|
expandSignal,
|
|
collapseSignal,
|
|
filterMode,
|
|
searchQuery: searchResults !== null ? searchQuery : '',
|
|
onToggleEnabled: handleToggleEnabled,
|
|
onToggleVisible: handleToggleVisible,
|
|
onDeletePage: handleDeletePage,
|
|
catalogType,
|
|
}}
|
|
>
|
|
<TooltipProvider delayDuration={400}>
|
|
<div className="rounded-lg border bg-card shadow-sm">
|
|
{/* Header */}
|
|
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<FolderTree className="h-4 w-4 text-primary" />
|
|
<h3 className="text-sm font-semibold">Catalog Structure</h3>
|
|
<Badge variant="secondary" className="text-[10px] h-5">
|
|
{displayPages.length}
|
|
{filterMode !== 'all' ? ` / ${unfilteredPages.length}` : ''} pages
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{/* Status filter */}
|
|
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterMode('all')}
|
|
className={cn(
|
|
'px-2 py-1 rounded text-xs font-medium transition-colors',
|
|
filterMode === 'all'
|
|
? 'bg-background shadow-sm text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
All
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<p>Show all pages</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterMode('enabled')}
|
|
className={cn(
|
|
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
|
|
filterMode === 'enabled'
|
|
? 'bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
<ToggleRight className="h-3 w-3" />
|
|
On
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<p>Show enabled only</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilterMode('disabled')}
|
|
className={cn(
|
|
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
|
|
filterMode === 'disabled'
|
|
? 'bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
<ToggleLeft className="h-3 w-3" />
|
|
Off
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<p>Show disabled only</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Expand / Collapse all */}
|
|
{searchResults === null && (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setExpandSignal((s) => s + 1)}
|
|
>
|
|
<ChevronsUpDown className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<p>Expand all</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setCollapseSignal((s) => s + 1)}
|
|
>
|
|
<ChevronsDownUp className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<p>Collapse all</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
|
|
{/* Search */}
|
|
<div className="relative w-56">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
ref={searchInputRef}
|
|
placeholder="Search pages... /"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-8 pl-8 pr-8 text-sm"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
setSearchQuery('')
|
|
searchInputRef.current?.blur()
|
|
}
|
|
}}
|
|
/>
|
|
{searching ? (
|
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
) : (
|
|
searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tree body */}
|
|
<div className="p-1.5 max-h-[calc(100vh-280px)] overflow-y-auto">
|
|
{searchResults !== null && (
|
|
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">
|
|
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for "
|
|
{searchQuery}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
{displayPages.map((page) => (
|
|
<TreeItem
|
|
key={page.id}
|
|
node={page}
|
|
level={0}
|
|
isSearchResult={searchResults !== null}
|
|
defaultExpanded={searchResults === null}
|
|
/>
|
|
))}
|
|
{displayPages.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
{searchResults !== null ? 'No pages match your search' : 'No catalog pages found'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
</TreeCtx.Provider>
|
|
)
|
|
}
|
|
|
|
/* ─── Tree Item ──────────────────────────────────────────── */
|
|
|
|
function TreeItem({
|
|
node,
|
|
level,
|
|
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
|
isSearchResult,
|
|
defaultExpanded,
|
|
}: {
|
|
node: TreeNode
|
|
level: number
|
|
isSearchResult?: boolean
|
|
defaultExpanded?: boolean
|
|
}) {
|
|
const {
|
|
expandSignal,
|
|
collapseSignal,
|
|
filterMode,
|
|
searchQuery,
|
|
onToggleEnabled,
|
|
onToggleVisible,
|
|
onDeletePage,
|
|
catalogType,
|
|
} = useContext(TreeCtx)
|
|
const router = useRouter()
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [children, setChildren] = useState<TreeNode[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [loaded, setLoaded] = useState(false)
|
|
const hasChildren = node.childCount > 0
|
|
|
|
// Auto-expand root level on mount
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
useEffect(() => {
|
|
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
|
|
loadChildren().then(() => setExpanded(true))
|
|
}
|
|
}, [])
|
|
|
|
// Expand all signal
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
useEffect(() => {
|
|
if (expandSignal > 0 && hasChildren) {
|
|
if (!loaded) {
|
|
loadChildren().then(() => setExpanded(true))
|
|
} else {
|
|
setExpanded(true)
|
|
}
|
|
}
|
|
}, [expandSignal])
|
|
|
|
// Collapse all signal
|
|
useEffect(() => {
|
|
if (collapseSignal > 0) {
|
|
setExpanded(false)
|
|
}
|
|
}, [collapseSignal])
|
|
|
|
const loadChildren = useCallback(async () => {
|
|
if (loaded || !hasChildren) return
|
|
setLoading(true)
|
|
try {
|
|
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
|
|
const res = await fetch(`/api/admin/catalog/tree?parentId=${node.id}${catParam}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setChildren(data.pages || [])
|
|
setLoaded(true)
|
|
}
|
|
} catch (error) {
|
|
console.error('[CatalogTree] Failed to load children:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [node.id, loaded, hasChildren, catalogType])
|
|
|
|
const handleToggle = () => {
|
|
if (!expanded && !loaded) {
|
|
loadChildren().then(() => setExpanded(true))
|
|
return
|
|
}
|
|
setExpanded(!expanded)
|
|
}
|
|
|
|
const isDisabled = node.enabled !== '1'
|
|
const isHidden = node.visible !== '1'
|
|
const isEmpty = node.itemCount === 0 && node.childCount === 0
|
|
const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground'
|
|
const italianName = translateCaption(node.caption)
|
|
const displayName = italianName ?? node.caption
|
|
|
|
return (
|
|
<div>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default',
|
|
isDisabled && 'opacity-40',
|
|
isEmpty && !isDisabled && 'opacity-60',
|
|
)}
|
|
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
|
onDoubleClick={() =>
|
|
router.push(
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog/${node.id}`,
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
router.push(
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog/${node.id}`,
|
|
)
|
|
}
|
|
}}
|
|
>
|
|
{/* Expand/collapse toggle */}
|
|
{hasChildren ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleToggle}
|
|
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight
|
|
className={cn(
|
|
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
|
|
expanded && 'rotate-90',
|
|
)}
|
|
/>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="h-5 w-5 shrink-0" />
|
|
)}
|
|
|
|
{/* Catalog icon */}
|
|
<CatalogIcon iconImage={node.iconImage} />
|
|
|
|
{/* Caption link */}
|
|
<Link
|
|
href={
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog/${node.id}`
|
|
}
|
|
className={cn(
|
|
'flex-1 min-w-0 hover:text-primary transition-colors',
|
|
isEmpty && !isDisabled ? 'text-muted-foreground italic' : '',
|
|
)}
|
|
>
|
|
<span className={cn('text-sm truncate block', !isEmpty && 'font-medium')}>
|
|
<HighlightText text={displayName} query={searchQuery} />
|
|
</span>
|
|
{italianName && (
|
|
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
|
|
<HighlightText text={node.caption} query={searchQuery} />
|
|
</span>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Quick toggle enabled on hover */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onToggleEnabled(node)
|
|
}}
|
|
className={cn(
|
|
'flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity',
|
|
isDisabled
|
|
? 'hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]'
|
|
: 'hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]',
|
|
)}
|
|
>
|
|
{isDisabled ? (
|
|
<Power className="h-3 w-3" />
|
|
) : (
|
|
<PowerOff className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<p>{isDisabled ? 'Enable' : 'Disable'}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Quick add subpage */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
router.push(
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog?newPageParent=${node.id}`,
|
|
)
|
|
}}
|
|
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<p>Create subpage</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Status indicators */}
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{isHidden && <EyeOff className="h-3 w-3 text-muted-foreground" />}
|
|
{isDisabled && (
|
|
<Badge variant="destructive" className="h-4 px-1 text-[9px]">
|
|
OFF
|
|
</Badge>
|
|
)}
|
|
{node.itemCount > 0 && (
|
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-mono">
|
|
{node.itemCount}
|
|
</Badge>
|
|
)}
|
|
{/* Layout badge on hover */}
|
|
<Badge
|
|
className={cn(
|
|
'h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
|
|
layoutColor,
|
|
)}
|
|
>
|
|
{node.pageLayout.replace('default_3x3', '3x3')}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="text-xs max-w-72 p-3">
|
|
<p className="font-semibold mb-1.5">
|
|
{node.caption} <span className="text-muted-foreground font-normal">#{node.id}</span>
|
|
</p>
|
|
<table className="w-full">
|
|
<tbody>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Layout</td>
|
|
<td className="py-px">{node.pageLayout}</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Items</td>
|
|
<td className="py-px">{node.itemCount}</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Subpages</td>
|
|
<td className="py-px">{node.childCount}</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Icon</td>
|
|
<td className="py-px">{node.iconImage}</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Visible</td>
|
|
<td className="py-px">{node.visible === '1' ? 'Yes' : 'No'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="text-muted-foreground pr-3 py-px">Enabled</td>
|
|
<td className="py-px">{node.enabled === '1' ? 'Yes' : 'No'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p className="text-muted-foreground mt-2 text-[10px]">
|
|
Right-click for actions · Double-click to open
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</ContextMenuTrigger>
|
|
|
|
{/* Right-click context menu */}
|
|
<ContextMenuContent className="w-48">
|
|
<ContextMenuLabel>
|
|
{node.caption} #{node.id}
|
|
</ContextMenuLabel>
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
router.push(
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog/${node.id}`,
|
|
)
|
|
}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
Edit Page
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => navigator.clipboard.writeText(String(node.id))}>
|
|
<Copy className="h-4 w-4" />
|
|
Copy ID
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onClick={() => onToggleEnabled(node)}>
|
|
{node.enabled === '1' ? (
|
|
<PowerOff className="h-4 w-4" />
|
|
) : (
|
|
<Power className="h-4 w-4" />
|
|
)}
|
|
{node.enabled === '1' ? 'Disable' : 'Enable'}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onClick={() => onToggleVisible(node)}>
|
|
{node.visible === '1' ? (
|
|
<EyeOffIcon className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
{node.visible === '1' ? 'Hide' : 'Show'}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
router.push(
|
|
catalogType === 'bc'
|
|
? `/admin/catalog/builder-club/${node.id}`
|
|
: `/admin/catalog?newPageParent=${node.id}`,
|
|
)
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Create Subpage
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem variant="destructive" onClick={() => onDeletePage(node)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
{/* Children */}
|
|
{expanded &&
|
|
children.length > 0 &&
|
|
(() => {
|
|
const filteredChildren =
|
|
filterMode === 'all'
|
|
? children
|
|
: children.filter((c) =>
|
|
filterMode === 'enabled' ? c.enabled === '1' : c.enabled !== '1',
|
|
)
|
|
return filteredChildren.length > 0 ? (
|
|
<div className="relative">
|
|
{/* Tree guide line */}
|
|
<div
|
|
className="absolute top-0 bottom-0 border-l border-border/40"
|
|
style={{ left: `${level * 20 + 18}px` }}
|
|
/>
|
|
{filteredChildren.map((child) => (
|
|
<TreeItem key={child.id} node={child} level={level + 1} />
|
|
))}
|
|
</div>
|
|
) : null
|
|
})()}
|
|
</div>
|
|
)
|
|
}
|