fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { ChevronRight, Home } from 'lucide-react'
import Link from 'next/link'
import { Fragment } from 'react'
export type { BreadcrumbItem } from '@/types/common'
import type { BreadcrumbItem } from '@/types/common'
interface AdminBreadcrumbProps {
items: BreadcrumbItem[]
}
export function AdminBreadcrumb({ items }: AdminBreadcrumbProps) {
return (
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-2">
<Link href="/admin" className="hover:text-foreground transition-colors">
<Home className="h-3.5 w-3.5" />
</Link>
{items.map((item) => (
<Fragment key={item.href ?? item.label}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
{item.href ? (
<Link href={item.href} className="hover:text-foreground transition-colors">
{item.label}
</Link>
) : (
<span className="text-foreground font-medium">{item.label}</span>
)}
</Fragment>
))}
</nav>
)
}
@@ -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} &middot; 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
}
+834
View File
@@ -0,0 +1,834 @@
'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-blue-500/15 text-blue-600 dark:text-blue-400',
frontpage: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
spaces_new: 'bg-green-500/15 text-green-600 dark:text-green-400',
pets: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
pets2: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
pets3: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
marketplace: 'bg-purple-500/15 text-purple-600 dark:text-purple-400',
recycler: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
club_buy: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400',
single_bundle: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
room_bundle: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
}
/* ─── 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-yellow-200 dark:bg-yellow-800/60 text-inherit 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-green-600 dark:text-green-400'
: '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-red-600 dark:text-red-400'
: '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 &quot;
{searchQuery}&quot;
</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-green-500/20 text-muted-foreground hover:text-green-600'
: 'hover:bg-red-500/20 text-muted-foreground hover:text-red-600',
)}
>
{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 &middot; 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>
)
}
@@ -0,0 +1,223 @@
'use client'
import { AlertTriangle, CheckCircle2, Loader2, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogItem } from '@/actions/catalog-items'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
interface ParsedRow {
lineNumber: number
raw: string
ok: boolean
error?: string
baseId?: number
credits?: number
points?: number
pointsType?: number
}
interface BulkImportItemsProps {
pageId: number
onImported?: () => void
}
export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
const [open, setOpen] = useState(false)
const [input, setInput] = useState('')
const [importing, setImporting] = useState(false)
const parsed = useMemo<ParsedRow[]>(() => {
const lines = input.split('\n')
const out: ParsedRow[] = []
lines.forEach((raw, idx) => {
const trimmed = raw.trim()
if (!trimmed || trimmed.startsWith('#')) return
const parts = trimmed.split(/[,\s;]+/).filter(Boolean)
const baseId = parseInt(parts[0], 10)
if (Number.isNaN(baseId) || baseId <= 0) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: 'Invalid base ID' })
return
}
const credits = parts[1] !== undefined ? parseInt(parts[1], 10) : 0
const points = parts[2] !== undefined ? parseInt(parts[2], 10) : 0
const pointsType = parts[3] !== undefined ? parseInt(parts[3], 10) : 0
if (
(parts[1] !== undefined && Number.isNaN(credits)) ||
(parts[2] !== undefined && Number.isNaN(points))
) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: 'Invalid number' })
return
}
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: true,
baseId,
credits,
points,
pointsType,
})
})
return out
}, [input])
const validCount = parsed.filter((r) => r.ok).length
const invalidCount = parsed.length - validCount
async function handleImport() {
const valid = parsed.filter((r) => r.ok)
if (valid.length === 0) {
toast.error('No valid rows to import')
return
}
setImporting(true)
let success = 0
let failed = 0
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: '',
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
})
if (res.ok) success++
else failed++
} catch {
failed++
}
}
setImporting(false)
if (success > 0) {
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ''}.`)
setInput('')
setOpen(false)
onImported?.()
} else {
toast.error(`All ${failed} imports failed.`)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
Bulk Import
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bulk Import Items</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Item list</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`One per line: base_id[, credits[, points[, points_type]]]\n# Lines starting with # are ignored\n12345, 50\n12346, 0, 10, 5\n12347`}
rows={10}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Format:{' '}
<code className="rounded bg-muted px-1">base_id, credits, points, points_type</code>.
Only <code>base_id</code> is required; other fields default to 0. Separators: comma,
space, or semicolon.
</p>
</div>
{parsed.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3 text-xs">
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{validCount} valid
</Badge>
{invalidCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{invalidCount} invalid
</Badge>
)}
</div>
<div className="max-h-[200px] overflow-y-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr>
<th className="px-2 py-1 text-left">Line</th>
<th className="px-2 py-1 text-left">Base ID</th>
<th className="px-2 py-1 text-left">Credits</th>
<th className="px-2 py-1 text-left">Points</th>
<th className="px-2 py-1 text-left">Type</th>
<th className="px-2 py-1 text-left">Status</th>
</tr>
</thead>
<tbody>
{parsed.map((row) => (
<tr key={row.lineNumber} className={row.ok ? '' : 'bg-destructive/10'}>
<td className="px-2 py-0.5 font-mono text-muted-foreground">
{row.lineNumber}
</td>
<td className="px-2 py-0.5 font-mono">{row.baseId ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.credits ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.points ?? '—'}</td>
<td className="px-2 py-0.5 font-mono">{row.pointsType ?? '—'}</td>
<td className="px-2 py-0.5">
{row.ok ? (
<span className="text-green-600">OK</span>
) : (
<span className="text-destructive">{row.error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || validCount === 0}>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? 's' : ''}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,253 @@
'use client'
import { ImageIcon, Loader2, Search, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface CatalogImagePickerProps {
value: string
onChange: (name: string) => void
/** Filter: 'header' | 'teaser' | 'all' */
type?: 'header' | 'teaser' | 'all'
disabled?: boolean
label?: string
}
const PAGE_SIZE = 80
export function CatalogImagePicker({
value,
onChange,
type = 'all',
disabled,
label,
}: CatalogImagePickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [images, setImages] = useState<string[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const scrollRef = useRef<HTMLDivElement>(null)
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
type,
})
if (debounced) params.set('search', debounced)
const res = await fetch(`/api/admin/catalog/images?${params}`)
if (!res.ok) throw new Error()
const data = await res.json()
const fetched: string[] = data.images ?? []
setTotal(data.total ?? 0)
setHasMore(nextOffset + fetched.length < (data.total ?? 0))
setImages((prev) => (replace ? fetched : [...prev, ...fetched]))
setOffset(nextOffset + fetched.length)
} catch {
// ignore
} finally {
setLoading(false)
}
},
[debounced, type],
)
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return
setImages([])
setOffset(0)
setHasMore(true)
fetchImages(0, true)
if (scrollRef.current) scrollRef.current.scrollTop = 0
}, [open, debounced, fetchImages])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false)
}
}, [loading, hasMore, offset, fetchImages])
const handleSelect = (name: string) => {
onChange(name)
setOpen(false)
}
const handleClear = () => {
onChange('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto w-full justify-start gap-2 px-3 py-2"
>
{value ? (
<CatalogImageThumb name={value} size={40} />
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-muted">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 text-left">
<p className="text-xs font-medium truncate">
{label ||
(type === 'header'
? 'Headline image'
: type === 'teaser'
? 'Teaser image'
: 'Image')}
</p>
<p className="text-[11px] text-muted-foreground truncate font-mono">
{value || 'None selected'}
</p>
</div>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>
{type === 'header'
? 'Select Headline Image'
: type === 'teaser'
? 'Select Teaser Image'
: 'Select Catalog Image'}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by filename..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} images
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
Clear
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[480px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{images.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No images found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
{images.map((name) => (
<button
key={name}
type="button"
onClick={() => handleSelect(name)}
className={cn(
'group relative flex flex-col overflow-hidden rounded-md border bg-card transition-colors hover:border-primary hover:shadow-sm',
value === name && 'border-primary ring-2 ring-primary/30',
)}
title={name}
>
<div className="flex h-24 items-center justify-center bg-gradient-to-b from-muted/30 to-muted/60 p-1">
<CatalogImageThumb name={name} size={80} />
</div>
<div className="border-t bg-background/95 px-2 py-1">
<p className="truncate font-mono text-[10px] text-muted-foreground group-hover:text-foreground">
{name}
</p>
</div>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
// ── Thumbnail with gif/png fallback ─────────────────────────────
function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number }) {
const [error, setError] = useState(0)
// Reset error state when name changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(0), [name])
if (!name || error >= 2) {
return (
<div
className="flex items-center justify-center rounded bg-muted"
style={{ width: size, height: size * 0.6 }}
>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)
}
// Try .png first, fallback to .gif
const src =
error === 0 ? `/swf/c_images/catalogue/${name}.png` : `/swf/c_images/catalogue/${name}.gif`
return (
<img
src={src}
alt={name}
className="object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size * 0.75 }}
onError={() => setError((e) => e + 1)}
/>
)
}
@@ -0,0 +1,213 @@
'use client'
import { Loader2, Package, Search, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface IconPickerProps {
value: number
onChange: (iconImage: number) => void
disabled?: boolean
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode
}
const PAGE_SIZE = 120
export function IconPicker({ value, onChange, disabled, children }: IconPickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [icons, setIcons] = useState<number[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const scrollRef = useRef<HTMLDivElement>(null)
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
})
if (debounced) params.set('search', debounced)
const res = await fetch(`/api/admin/catalog/icons?${params}`)
if (!res.ok) throw new Error('Failed to load icons')
const data = await res.json()
const fetched: number[] = data.icons ?? []
setTotal(data.total ?? 0)
setHasMore(nextOffset + fetched.length < (data.total ?? 0))
setIcons((prev) => (replace ? fetched : [...prev, ...fetched]))
setOffset(nextOffset + fetched.length)
} catch (err) {
console.error('[IconPicker]', err)
} finally {
setLoading(false)
}
},
[debounced],
)
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return
setIcons([])
setOffset(0)
setHasMore(true)
fetchIcons(0, true)
if (scrollRef.current) scrollRef.current.scrollTop = 0
}, [open, debounced, fetchIcons])
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false)
}
}, [loading, hasMore, offset, fetchIcons])
const handleSelect = (id: number) => {
onChange(id)
setOpen(false)
}
const handleClear = () => {
onChange(0)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto gap-2 px-3 py-2"
>
<IconPreview iconImage={value} size={24} />
<span className="font-mono text-xs text-muted-foreground">
{value > 0 ? `#${value}` : 'None'}
</span>
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Select Catalog Icon</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by icon ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} icons
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
<Package className="mr-1.5 h-3.5 w-3.5" />
None
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[420px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{icons.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No icons found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(56px,1fr))] gap-1.5">
{icons.map((id) => (
<button
key={id}
type="button"
onClick={() => handleSelect(id)}
className={cn(
'group relative flex aspect-square items-center justify-center rounded border bg-card p-1 transition-colors hover:border-primary hover:bg-accent',
value === id && 'border-primary ring-2 ring-primary/30',
)}
title={`Icon #${id}`}
>
<IconPreview iconImage={id} size={36} />
<span className="absolute bottom-0 left-0 right-0 truncate bg-background/80 px-0.5 text-center font-mono text-[9px] leading-tight text-muted-foreground opacity-0 group-hover:opacity-100">
{id}
</span>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
// ── Small preview component (inlined to avoid circular imports) ───────
function IconPreview({ iconImage, size = 24 }: { iconImage: number; size?: number }) {
const [error, setError] = useState(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage])
if (error || iconImage <= 0) {
return (
<Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />
)
}
return (
<img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
)
}
@@ -0,0 +1,218 @@
'use client'
import { Crown, Image as ImageIcon, Package, Sparkles } from 'lucide-react'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { getPointsLabel } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { Badge } from '@/components/ui/badge'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
import { cn } from '@/lib/utils'
interface ItemsShopPreviewProps {
items: CatalogItemData[]
furniRevisionMap: Record<number, { classname: string; revision: number }>
layout: string
canEdit: boolean
onItemClick?: (item: CatalogItemData) => void
}
export function ItemsShopPreview({
items,
furniRevisionMap,
layout,
canEdit,
onItemClick,
}: ItemsShopPreviewProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No items in this page yet</p>
</div>
)
}
const gridClass = getGridClass(layout)
const tileSize = getTileSize(layout)
return (
<div className="rounded-lg border bg-gradient-to-b from-sky-50 to-sky-100 dark:from-slate-900 dark:to-slate-950 p-4">
<div className="mb-3 flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn('grid gap-2', gridClass)}>
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds)
const furni = firstId != null ? furniRevisionMap[firstId] : undefined
const points = getPointsLabel(item.pointsType)
const isClub = item.clubOnly === '1'
const isOffer = item.haveOffer === '1' && item.offerId > 0
return (
<button
key={item.id}
type="button"
onClick={() => onItemClick?.(item)}
disabled={!canEdit && !onItemClick}
className={cn(
'group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all',
canEdit && 'hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer',
!canEdit && 'cursor-default',
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ''}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-yellow-500/90 px-1 text-[8px] text-white">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-rose-500/90 px-1 text-[8px] text-white">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-black/70 px-1 text-[9px] text-white">
x{item.amount}
</Badge>
)}
{/* Limited */}
{item.limitedStack > 0 && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-purple-600/90 px-1 text-[8px] text-white">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="text-yellow-600 dark:text-yellow-500">
{item.costCredits}c
</span>
)}
{item.costPoints > 0 && (
<span className={points.color}>
{item.costPoints} {points.label.slice(0, 3)}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ── Helpers ─────────────────────────────────────────────────────
function parseFirstItemId(itemIds: string): number | null {
if (!itemIds) return null
const first = itemIds.split(';')[0]?.trim()
const n = parseInt(first, 10)
return Number.isNaN(n) ? null : n
}
function getGridClass(layout: string): string {
switch (layout) {
case 'spaces_new':
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
case 'frontpage':
return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'
case 'single_bundle':
case 'room_bundle':
return 'grid-cols-1 sm:grid-cols-2'
case 'pets':
case 'pets2':
case 'pets3':
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
default:
return 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'
}
}
function getTileSize(layout: string): number {
switch (layout) {
case 'frontpage':
case 'single_bundle':
case 'room_bundle':
return 140
case 'spaces_new':
return 110
default:
return 96
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string
revision: number
size: number
}) {
if (!classname) {
return (
<ImageIcon
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
)
}
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = '0'
}
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
)
}
@@ -0,0 +1,228 @@
'use client'
import { Loader2, Package, Search, Zap } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { createCatalogItem } from '@/actions/catalog-items'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useServerAction } from '@/hooks/use-server-action'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
interface FurniResult {
id: number
itemName: string
publicName: string
type: string
spriteId: number
}
interface QuickAddFurniProps {
pageId: number
furniRevisionMap: Record<number, { classname: string; revision: number }>
}
export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [results, setResults] = useState<FurniResult[]>([])
const [loading, setLoading] = useState(false)
const { run, isPending } = useServerAction()
const inputRef = useRef<HTMLInputElement>(null)
// Keyboard shortcut: Ctrl+K / Cmd+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
e.preventDefault()
setOpen(true)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300)
return () => clearTimeout(t)
}, [query])
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([])
return
}
setLoading(true)
const ac = new AbortController()
fetch(`/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=30`, {
signal: ac.signal,
})
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.finally(() => setLoading(false))
return () => ac.abort()
}, [debounced, open])
const handleAdd = useCallback(
(base: FurniResult) => {
run(
() =>
createCatalogItem({
pageId,
itemIds: String(base.id),
catalogName: base.publicName || base.itemName,
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: 'Failed to add item.',
onSuccess: () => {
toast.success(`Added #${base.id}`)
},
},
)
},
[pageId, run],
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Zap className="mr-2 h-4 w-4" />
Quick Add
<kbd className="ml-2 hidden rounded border bg-muted px-1 text-[10px] sm:inline">
Ctrl+K
</kbd>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Quick Add Furni</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search by item name or public name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
{loading && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="max-h-[420px] overflow-y-auto rounded-md border">
{debounced.length < 2 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
) : results.length === 0 && !loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No furni found for &quot;{debounced}&quot;
</p>
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id]
return (
<button
key={r.id}
type="button"
onClick={() => handleAdd(r)}
disabled={isPending}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent disabled:opacity-50"
>
<FurniThumb
classname={furni?.classname ?? r.itemName}
revision={furni?.revision ?? 0}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{r.publicName || (
<span className="italic text-muted-foreground">{r.itemName}</span>
)}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
#{r.id} · {r.itemName} · {r.type}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">Add →</span>
</button>
)
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
function FurniThumb({ classname, revision }: { classname: string; revision: number }) {
if (!classname) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
<Package className="h-4 w-4 text-muted-foreground" />
</div>
)
}
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = '0'
}
}
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: 'pixelated' }}
onError={handleError}
loading="lazy"
/>
</div>
)
}
+517
View File
@@ -0,0 +1,517 @@
'use client'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Download,
Filter,
Search,
SlidersHorizontal,
X,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useDebounce } from '@/hooks/use-debounce'
import { PER_PAGE_OPTIONS } from '@/lib/admin-helpers'
import type { DataTableColumn, PaginatedResult } from '@/types'
/**
* One-click filter preset. The chip applies/clears the listed query-string
* keys at once; the preset is "active" when every key matches the URL state.
*/
export interface DataTablePreset {
label: string
params: Record<string, string>
}
interface DataTableProps<T extends { id: number | string }> {
data: PaginatedResult<T>
columns: DataTableColumn<T>[]
searchPlaceholder?: string
bulkActions?: { label: string; value: string; variant?: 'destructive' | 'default' }[]
onBulkAction?: (action: string, ids: (number | string)[]) => Promise<void>
exportUrl?: string
presets?: DataTablePreset[]
}
export function DataTable<T extends { id: number | string }>({
data,
columns,
searchPlaceholder = 'Search...',
bulkActions,
onBulkAction,
exportUrl,
presets,
}: DataTableProps<T>) {
const router = useRouter()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set())
const [searchValue, setSearchValue] = useState(searchParams.get('search') || '')
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set())
const currentSort = searchParams.get('sort') || ''
const currentOrder = searchParams.get('order') || 'desc'
const debouncedSearch = useDebounce(searchValue, 300)
const isFirstRender = useRef(true)
// Collect active filters from URL
const filterColumns = columns.filter((col) => col.filterOptions && col.filterOptions.length > 0)
const activeFilters: Record<string, string> = {}
for (const col of filterColumns) {
const key = col.filterKey || `filter_${col.key}`
const val = searchParams.get(key)
if (val) activeFilters[key] = val
}
const activeFilterCount = Object.keys(activeFilters).length
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
updateParams({ search: debouncedSearch, page: '1' })
}, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(value: string) {
setSearchValue(value)
}
function handleSort(key: string) {
const newOrder = currentSort === key && currentOrder === 'asc' ? 'desc' : 'asc'
updateParams({ sort: key, order: newOrder })
}
function handlePageChange(page: number) {
updateParams({ page: String(page) })
}
function handlePerPageChange(perPage: string) {
updateParams({ perPage, page: '1' })
}
function handleFilterChange(col: DataTableColumn<T>, value: string) {
const key = col.filterKey || `filter_${col.key}`
updateParams({ [key]: value === '__all__' ? '' : value, page: '1' })
}
function clearAllFilters() {
const updates: Record<string, string> = { page: '1' }
for (const col of filterColumns) {
const key = col.filterKey || `filter_${col.key}`
updates[key] = ''
}
updateParams(updates)
}
function toggleSelectAll() {
if (selectedIds.size === data.rows.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(data.rows.map((r) => r.id)))
}
}
function toggleSelect(id: number | string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleBulkAction(action: string) {
if (selectedIds.size === 0 || !onBulkAction) return
await onBulkAction(action, Array.from(selectedIds))
setSelectedIds(new Set())
router.refresh()
}
function getSortIcon(key: string) {
if (currentSort !== key) return <ArrowUpDown className="h-3 w-3" />
return currentOrder === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
}
function isPresetActive(preset: DataTablePreset): boolean {
return Object.entries(preset.params).every(([k, v]) => (searchParams.get(k) ?? '') === v)
}
function applyPreset(preset: DataTablePreset) {
// Apply (or toggle off) the preset by overwriting only its keys + reset page.
const updates: Record<string, string> = { page: '1' }
if (isPresetActive(preset)) {
for (const k of Object.keys(preset.params)) updates[k] = ''
} else {
Object.assign(updates, preset.params)
}
updateParams(updates)
}
return (
<div className="space-y-4">
{/* Preset filter chips */}
{presets && presets.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{presets.map((preset) => {
const active = isPresetActive(preset)
return (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset)}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors ${
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-accent'
}`}
>
{preset.label}
{active && <X className="h-3 w-3" />}
</button>
)
})}
</div>
)}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Column Filters */}
{filterColumns.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center"
>
{activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Filters</h4>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearAllFilters}
>
Clear all
</Button>
)}
</div>
{filterColumns.map((col) => {
const key = col.filterKey || `filter_${col.key}`
const currentValue = searchParams.get(key) || '__all__'
return (
<div key={col.key} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{col.label}</span>
<Select
value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{col.filterOptions!.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
</PopoverContent>
</Popover>
)}
{/* Active Filter Badges */}
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{filterColumns.map((col) => {
const key = col.filterKey || `filter_${col.key}`
const val = searchParams.get(key)
if (!val) return null
const opt = col.filterOptions!.find((o) => o.value === val)
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{col.label}: {opt?.label || val}
<button
type="button"
onClick={() => handleFilterChange(col, '__all__')}
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
)}
{/* Column Visibility */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Columns
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-1">
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
{columns.map((col) => (
<div
key={col.key}
className="flex items-center gap-2 py-1 text-sm hover:text-foreground"
>
<Checkbox
checked={!hiddenColumns.has(col.key)}
onCheckedChange={(checked) => {
setHiddenColumns((prev) => {
const next = new Set(prev)
if (checked) next.delete(col.key)
else next.add(col.key)
return next
})
}}
/>
{col.label}
</div>
))}
</div>
</PopoverContent>
</Popover>
<Select value={String(data.perPage)} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / page
</SelectItem>
))}
</SelectContent>
</Select>
{exportUrl && (
<Button variant="outline" size="sm" asChild>
<a href={exportUrl}>
<Download className="mr-2 h-4 w-4" />
CSV
</a>
</Button>
)}
{bulkActions && selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{selectedIds.size} selected</span>
{bulkActions.map((action) => (
<Button
key={action.value}
variant={action.variant || 'default'}
size="sm"
onClick={() => handleBulkAction(action.value)}
>
{action.label}
</Button>
))}
</div>
)}
<div className="ml-auto text-sm text-muted-foreground">{data.total} results</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{bulkActions && (
<TableHead className="w-[40px]">
<Checkbox
checked={selectedIds.size === data.rows.length && data.rows.length > 0}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
)}
{columns
.filter((col) => !hiddenColumns.has(col.key))
.map((col) => (
<TableHead key={col.key}>
{col.sortable ? (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => handleSort(col.key)}
>
{col.label}
{getSortIcon(col.key)}
</button>
) : (
col.label
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={
columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)
}
className="h-24 text-center text-muted-foreground"
>
No results found.
</TableCell>
</TableRow>
) : (
data.rows.map((row) => (
<TableRow
key={row.id}
className={isPending ? 'opacity-50' : undefined}
data-selected={selectedIds.has(row.id) || undefined}
>
{bulkActions && (
<TableCell>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={() => toggleSelect(row.id)}
/>
</TableCell>
)}
{columns
.filter((col) => !hiddenColumns.has(col.key))
.map((col) => (
<TableCell key={col.key}>
{col.render
? col.render((row as Record<string, unknown>)[col.key] as T[keyof T], row)
: String((row as Record<string, unknown>)[col.key] ?? '')}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data.lastPage > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {data.page} of {data.lastPage}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
disabled={data.page <= 1}
onClick={() => handlePageChange(data.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(data.lastPage, 7) }, (_, i) => {
let pageNum: number
if (data.lastPage <= 7) {
pageNum = i + 1
} else if (data.page <= 4) {
pageNum = i + 1
} else if (data.page >= data.lastPage - 3) {
pageNum = data.lastPage - 6 + i
} else {
pageNum = data.page - 3 + i
}
return (
<Button
key={pageNum}
variant={pageNum === data.page ? 'default' : 'outline'}
size="icon"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
)
})}
<Button
variant="outline"
size="icon"
disabled={data.page >= data.lastPage}
onClick={() => handlePageChange(data.page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,412 @@
'use client'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
RotateCcw,
Save,
Search,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { saveEmulatorSettings } from '@/actions/emulator'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
interface Setting {
key: string
value: string
}
interface GroupSummary {
name: string
count: number
}
interface EmulatorSettingsFormProps {
settings: Setting[]
pagination: { total: number; page: number; perPage: number; lastPage: number }
search: string
allGroups?: GroupSummary[]
activeGroup?: string
}
const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const
function getSettingGroup(key: string): string {
const parts = key.split('.')
return parts.length > 1 ? parts[0] : 'general'
}
const GROUP_BORDER_COLORS: Record<string, string> = {
hotel: 'border-l-blue-500',
navigator: 'border-l-green-500',
catalog: 'border-l-purple-500',
room: 'border-l-orange-500',
camera: 'border-l-pink-500',
commands: 'border-l-cyan-500',
wordfilter: 'border-l-red-500',
pet: 'border-l-yellow-500',
matchmaking: 'border-l-indigo-500',
message: 'border-l-emerald-500',
notification: 'border-l-fuchsia-500',
general: 'border-l-gray-400',
}
export function EmulatorSettingsForm({
settings,
pagination,
search,
allGroups,
activeGroup,
}: EmulatorSettingsFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
// `values` only contains overrides typed by the user. Display falls back
// to `setting.value`. `originalValues` accumulates the on-disk values for
// every setting we have ever rendered (across pages) so we can detect when
// an edit returns to its original state.
const [values, setValues] = useState<Record<string, string>>({})
const [originalValues, setOriginalValues] = useState<Record<string, string>>({})
const [changedKeys, setChangedKeys] = useState<Set<string>>(new Set())
const [isNavigating, startTransition] = useTransition()
const { run, isPending } = useServerAction()
const [searchValue, setSearchValue] = useState(search)
useEffect(() => {
setOriginalValues((prev) => {
let dirty = false
const next = { ...prev }
for (const s of settings) {
if (!(s.key in next)) {
next[s.key] = s.value
dirty = true
}
}
return dirty ? next : prev
})
}, [settings])
// Prefer the canonical list passed by the server (covers all groups, not
// just those visible on the current page). Fall back to deriving from the
// current page when the prop is omitted.
const groupSummary = useMemo<GroupSummary[]>(() => {
if (allGroups && allGroups.length > 0) return allGroups
const map = new Map<string, number>()
for (const s of settings) {
const g = getSettingGroup(s.key)
map.set(g, (map.get(g) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name))
}, [allGroups, settings])
function toggleGroup(name: string) {
updateParams({
group: activeGroup === name ? '' : name,
page: '1',
})
}
function getDisplayValue(setting: Setting): string {
return values[setting.key] ?? setting.value
}
function updateValue(key: string, val: string) {
setValues((prev) => ({ ...prev, [key]: val }))
setChangedKeys((prev) => {
const next = new Set(prev)
if (originalValues[key] !== undefined && val === originalValues[key]) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
function revertChange(key: string) {
setValues((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setChangedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
updateParams({ search: searchValue, page: '1' })
}
function handleSaveAll() {
if (changedKeys.size === 0) {
toast.info('No changes to save.')
return
}
const changedSettings: Record<string, string> = {}
for (const key of changedKeys) {
changedSettings[key] = values[key]
}
const count = changedKeys.size
run(() => saveEmulatorSettings({ settings: changedSettings }), {
successMessage: `${count} setting(s) saved and synced with emulator.`,
onSuccess: () => {
setChangedKeys(new Set())
setValues({})
// Refresh server data so the on-disk values reflect the save and the
// originalValues snapshot rebuilds on next render.
setOriginalValues({})
router.refresh()
},
})
}
function jumpToPage(raw: string) {
const n = parseInt(raw, 10)
if (!Number.isFinite(n)) return
const clamped = Math.max(1, Math.min(pagination.lastPage, n))
if (clamped !== pagination.page) updateParams({ page: String(clamped) })
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search settings..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Search
</Button>
</form>
<div className="ml-auto flex items-center gap-3">
<span className="text-sm text-muted-foreground">{pagination.total} settings</span>
{changedKeys.size > 0 && (
<Badge variant="secondary" className="gap-1">
{changedKeys.size} changed
</Badge>
)}
<Button onClick={handleSaveAll} disabled={isPending || changedKeys.size === 0} size="sm">
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
</div>
</div>
{/* Group summary chips — click to toggle group filter */}
{groupSummary.length > 1 && (
<div className="flex flex-wrap gap-1.5 items-center">
{activeGroup && (
<button
type="button"
onClick={() => toggleGroup(activeGroup)}
className="text-[11px] underline text-muted-foreground hover:text-foreground"
>
clear
</button>
)}
{groupSummary.map(({ name, count }) => {
const isActive = activeGroup === name
const color = (GROUP_BORDER_COLORS[name] || GROUP_BORDER_COLORS.general).replace(
'border-l-',
'bg-',
)
return (
<button
key={name}
type="button"
onClick={() => toggleGroup(name)}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md border text-[10px] font-mono transition-colors ${
isActive
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isActive ? `Rimuovi filtro: ${name}` : `Filtra: ${name}`}
disabled={isNavigating}
>
<span className={`inline-block w-2 h-2 rounded-full ${color}`} />
{name}
<span className="text-muted-foreground">{count}</span>
</button>
)
})}
</div>
)}
{/* Settings list — flat rows with always-on inline editing */}
<div className="rounded-md border bg-card divide-y">
{settings.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No settings found.
</div>
) : (
settings.map((setting) => {
const group = getSettingGroup(setting.key)
const borderColor = GROUP_BORDER_COLORS[group] || GROUP_BORDER_COLORS.general
const isChanged = changedKeys.has(setting.key)
const display = getDisplayValue(setting)
return (
<div
key={setting.key}
className={`flex items-center gap-3 px-3 py-1.5 border-l-4 ${borderColor} ${
isChanged ? 'bg-yellow-50/60 dark:bg-yellow-950/20' : ''
}`}
>
<code
className="text-xs font-mono text-muted-foreground w-[280px] flex-shrink-0 truncate"
title={setting.key}
>
{setting.key}
</code>
<Input
value={display}
onChange={(e) => updateValue(setting.key, e.target.value)}
className={`flex-1 h-8 text-xs font-mono ${
isChanged ? 'border-yellow-500/60' : ''
}`}
/>
{isChanged && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Revert"
onClick={() => revertChange(setting.key)}
>
<RotateCcw className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
)
})
)}
</div>
{/* Pagination + per-page selector */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Per page</span>
<Select
value={String(pagination.perPage)}
onValueChange={(v) => updateParams({ perPage: v, page: '1' })}
>
<SelectTrigger size="sm" className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-2">
{pagination.total === 0
? '0 results'
: `${(pagination.page - 1) * pagination.perPage + 1}–${Math.min(
pagination.page * pagination.perPage,
pagination.total,
)} of ${pagination.total}`}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: '1' })}
title="First page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: String(pagination.page - 1) })}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 mx-1 text-sm">
<Input
type="number"
min={1}
max={pagination.lastPage}
defaultValue={pagination.page}
key={pagination.page}
onBlur={(e) => jumpToPage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToPage((e.target as HTMLInputElement).value)
}}
className="h-8 w-14 text-center text-xs"
/>
<span className="text-muted-foreground">of {pagination.lastPage}</span>
</div>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.page + 1) })}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.lastPage) })}
title="Last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
'use client'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { toggleUserWatch } from '@/actions/watch'
import { Button } from '@/components/ui/button'
interface UserWatchToggleProps {
targetUserId: number
username: string
initialWatching: boolean
}
export function UserWatchToggle({ targetUserId, username, initialWatching }: UserWatchToggleProps) {
const [watching, setWatching] = useState(initialWatching)
const [busy, setBusy] = useState(false)
async function handleToggle() {
setBusy(true)
try {
const result = await toggleUserWatch({ targetUserId })
if (result.ok) {
const data = result.data as { watching: boolean }
setWatching(data.watching)
toast.success(data.watching ? `Now watching ${username}` : `Stopped watching ${username}`)
} else {
toast.error(result.error)
}
} finally {
setBusy(false)
}
}
return (
<Button
variant={watching ? 'default' : 'outline'}
size="sm"
disabled={busy}
onClick={handleToggle}
title={watching ? 'Stop watching this user' : 'Watch this user'}
>
{watching ? (
<>
<Eye className="mr-1.5 h-3.5 w-3.5" /> Watching
</>
) : (
<>
<EyeOff className="mr-1.5 h-3.5 w-3.5" /> Watch
</>
)}
</Button>
)
}