fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -0,0 +1,657 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Languages,
|
||||
Loader2,
|
||||
Package,
|
||||
Save,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { updateCatalogPage } from '@/actions/catalog'
|
||||
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table'
|
||||
import { CatalogItemsTable } from '@/app/admin/catalog/[id]/catalog-items-table'
|
||||
import { CatalogTranslateTab } from '@/app/admin/catalog/[id]/catalog-translate-tab'
|
||||
import { BulkImportItems } from '@/components/admin/catalog/bulk-import-items'
|
||||
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
|
||||
import { IconPicker } from '@/components/admin/catalog/icon-picker'
|
||||
import { ItemsShopPreview } from '@/components/admin/catalog/items-shop-preview'
|
||||
import { QuickAddFurni } from '@/components/admin/catalog/quick-add-furni'
|
||||
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ImagePreview } from './image-preview'
|
||||
import { useCatalogManager } from './use-catalog-store'
|
||||
|
||||
interface PageDetail {
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
clubOnly: string
|
||||
orderNum: number
|
||||
iconImage: number
|
||||
iconColor: number
|
||||
pageHeadline: string
|
||||
pageTeaser: string
|
||||
pageSpecial: string | null
|
||||
pageText1: string | null
|
||||
pageText2: string | null
|
||||
pageTextDetails: string | null
|
||||
pageTextTeaser: string | null
|
||||
itemCount?: number
|
||||
}
|
||||
|
||||
interface ItemsData {
|
||||
items: CatalogItemData[]
|
||||
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
|
||||
catalogNameMap: Record<number, string>
|
||||
furniDataIdList: number[]
|
||||
furniDescriptionMap: Record<number, string>
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>
|
||||
interactionTypes: string[]
|
||||
allPages: { id: number; caption: string }[]
|
||||
}
|
||||
|
||||
export interface PageUpdateData {
|
||||
id: number
|
||||
caption: string
|
||||
enabled: string
|
||||
visible: string
|
||||
pageLayout: string
|
||||
iconImage: number
|
||||
}
|
||||
|
||||
interface InlineEditorProps {
|
||||
pageId: number | null
|
||||
onSaved: (updated: PageUpdateData) => void
|
||||
}
|
||||
|
||||
type FormState = Record<string, string | number | null>
|
||||
|
||||
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
const { dispatch } = useCatalogManager()
|
||||
const [page, setPage] = useState<PageDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<FormState>({})
|
||||
const [original, setOriginal] = useState<FormState>({})
|
||||
const [showTexts, setShowTexts] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('settings')
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
// Items/translate data (lazy loaded)
|
||||
const [itemsData, setItemsData] = useState<ItemsData | null>(null)
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [itemsLoaded, setItemsLoaded] = useState(false)
|
||||
|
||||
// ── Dirty tracking ──────────────────────────────────────────
|
||||
const isDirty = useMemo(() => {
|
||||
return Object.keys(form).some((k) => form[k] !== original[k])
|
||||
}, [form, original])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_DIRTY', dirty: isDirty })
|
||||
}, [isDirty, dispatch])
|
||||
|
||||
// ── Ctrl+S handler ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (isDirty && !saving && page) handleSave()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadPage = useCallback(async (id: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json()
|
||||
const p = data.page as PageDetail
|
||||
setPage(p)
|
||||
const formData: FormState = {
|
||||
caption: p.caption,
|
||||
pageLayout: p.pageLayout,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
minRank: p.minRank,
|
||||
clubOnly: String(p.clubOnly),
|
||||
orderNum: p.orderNum,
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageHeadline: p.pageHeadline || '',
|
||||
pageTeaser: p.pageTeaser || '',
|
||||
pageSpecial: p.pageSpecial || '',
|
||||
pageText1: p.pageText1 || '',
|
||||
pageText2: p.pageText2 || '',
|
||||
pageTextDetails: p.pageTextDetails || '',
|
||||
pageTextTeaser: p.pageTextTeaser || '',
|
||||
}
|
||||
setForm(formData)
|
||||
setOriginal(formData)
|
||||
} catch {
|
||||
toast.error('Failed to load page')
|
||||
setPage(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadItemsData = useCallback(async (id: number) => {
|
||||
setItemsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`)
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json()
|
||||
setItemsData(data as ItemsData)
|
||||
setItemsLoaded(true)
|
||||
} catch {
|
||||
toast.error('Failed to load items')
|
||||
} finally {
|
||||
setItemsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reloadItems = useCallback(() => {
|
||||
if (pageId && pageId > 0) loadItemsData(pageId)
|
||||
}, [pageId, loadItemsData])
|
||||
|
||||
useEffect(() => {
|
||||
if (pageId && pageId > 0) {
|
||||
loadPage(pageId)
|
||||
setItemsData(null)
|
||||
setItemsLoaded(false)
|
||||
setActiveTab('settings')
|
||||
setShowPreview(false)
|
||||
} else {
|
||||
setPage(null)
|
||||
setForm({})
|
||||
setOriginal({})
|
||||
setItemsData(null)
|
||||
setItemsLoaded(false)
|
||||
}
|
||||
}, [pageId, loadPage])
|
||||
|
||||
// Lazy-load items
|
||||
useEffect(() => {
|
||||
if (
|
||||
(activeTab === 'items' || activeTab === 'translate') &&
|
||||
!itemsLoaded &&
|
||||
!itemsLoading &&
|
||||
pageId &&
|
||||
pageId > 0
|
||||
) {
|
||||
loadItemsData(pageId)
|
||||
}
|
||||
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData])
|
||||
|
||||
function update(key: string, value: string | number | null) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!page) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await updateCatalogPage({ id: page.id, ...form })
|
||||
if (!result.ok) throw new Error(result.error || 'Failed to save')
|
||||
toast.success('Page updated & catalog synced')
|
||||
setOriginal({ ...form })
|
||||
setPage((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
caption: String(form.caption || prev.caption),
|
||||
enabled: String(form.enabled ?? prev.enabled),
|
||||
visible: String(form.visible ?? prev.visible),
|
||||
pageLayout: String(form.pageLayout || prev.pageLayout),
|
||||
iconImage: (form.iconImage as number) ?? prev.iconImage,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
onSaved({
|
||||
id: page.id,
|
||||
caption: String(form.caption || page.caption),
|
||||
enabled: String(form.enabled ?? page.enabled),
|
||||
visible: String(form.visible ?? page.visible),
|
||||
pageLayout: String(form.pageLayout || page.pageLayout),
|
||||
iconImage: (form.iconImage as number) ?? page.iconImage,
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageId || pageId <= 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
Select a page from the tree to edit
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!page) return null
|
||||
|
||||
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogIcon iconImage={(form.iconImage as number) ?? page.iconImage} size={24} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{(form.caption as string) || page.caption}</h3>
|
||||
<Badge className={cn('h-4 px-1 text-[8px] font-mono border-0', layoutColor)}>
|
||||
{form.pageLayout as string}
|
||||
</Badge>
|
||||
{isDirty && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1.5 text-[9px] text-orange-600 border-orange-300 bg-orange-50 dark:bg-orange-950/30 dark:border-orange-700"
|
||||
>
|
||||
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
|
||||
unsaved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{page.id} · Parent: {page.parentId === -1 ? 'Root' : `#${page.parentId}`}
|
||||
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/admin/catalog/${page.id}`} target="_blank">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Full editor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Dirty banner */}
|
||||
{isDirty && (
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-yellow-50 dark:bg-yellow-950/30 border-b border-yellow-200 dark:border-yellow-800 text-xs text-yellow-800 dark:text-yellow-200 shrink-0">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
Unsaved changes — press{' '}
|
||||
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to
|
||||
save
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="mx-4 mt-2 shrink-0">
|
||||
<TabsTrigger value="settings" className="gap-1.5 text-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-orange-500" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-1.5 text-xs">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Items
|
||||
{itemsData && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
|
||||
{itemsData.items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-1.5 text-xs">
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
Translate
|
||||
{itemsData &&
|
||||
(() => {
|
||||
const furniSet = new Set(itemsData.furniDataIdList)
|
||||
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length
|
||||
return missing > 0 ? (
|
||||
<span className="ml-0.5 rounded-full bg-orange-500 text-white text-[9px] px-1 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Settings tab ──────────────────────────────── */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
<div className="space-y-4">
|
||||
{/* Caption + Layout */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Caption</Label>
|
||||
<Input
|
||||
value={String(form.caption || '')}
|
||||
onChange={(e) => update('caption', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Layout</Label>
|
||||
<Select
|
||||
value={String(form.pageLayout || '')}
|
||||
onValueChange={(v) => update('pageLayout', v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATALOG_LAYOUTS.map((l) => {
|
||||
const color = LAYOUT_COLORS[l] || 'bg-muted text-muted-foreground'
|
||||
return (
|
||||
<SelectItem key={l} value={l}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full', color)} />
|
||||
{l}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? 'Custom layout'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order + Rank + Icon */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.orderNum as number}
|
||||
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Icon</Label>
|
||||
<IconPicker
|
||||
value={(form.iconImage as number) ?? 0}
|
||||
onChange={(v) => update('iconImage', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<ToggleRow
|
||||
id="ie-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
checked={form.enabled === '1'}
|
||||
onChange={(v) => update('enabled', v ? '1' : '0')}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-visible"
|
||||
label="Visible"
|
||||
description="Shown in navigation"
|
||||
checked={form.visible === '1'}
|
||||
onChange={(v) => update('visible', v ? '1' : '0')}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === '1'}
|
||||
onChange={(v) => update('clubOnly', v ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headline image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageHeadline || '')}
|
||||
onChange={(v) => update('pageHeadline', v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
/>
|
||||
<ImagePreview value={String(form.pageHeadline || '')} height={50} />
|
||||
</div>
|
||||
|
||||
{/* Teaser image picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={String(form.pageTeaser || '')}
|
||||
onChange={(v) => update('pageTeaser', v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
/>
|
||||
<ImagePreview value={String(form.pageTeaser || '')} height={50} />
|
||||
</div>
|
||||
|
||||
{/* Collapsible text fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTexts(!showTexts)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTexts ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Text fields
|
||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||
has content
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showTexts && (
|
||||
<div className="space-y-3 pl-2 border-l-2 border-border/40">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 1</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText1 || '')}
|
||||
onChange={(e) => update('pageText1', e.target.value || null)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text 2</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageText2 || '')}
|
||||
onChange={(e) => update('pageText2', e.target.value || null)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text Details</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageTextDetails || '')}
|
||||
onChange={(e) => update('pageTextDetails', e.target.value || null)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Special</Label>
|
||||
<Input
|
||||
value={String(form.pageSpecial || '')}
|
||||
onChange={(e) => update('pageSpecial', e.target.value || null)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSave} disabled={saving || !isDirty} className="w-full">
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Items tab ─────────────────────────────────── */}
|
||||
<TabsContent value="items" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
{itemsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<QuickAddFurni pageId={page.id} furniRevisionMap={itemsData.furniRevisionMap} />
|
||||
<BulkImportItems pageId={page.id} onImported={reloadItems} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{showPreview ? 'Hide Preview' : 'Shop Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Shop preview (togglable) */}
|
||||
{showPreview && (
|
||||
<ItemsShopPreview
|
||||
items={itemsData.items}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
layout={page.pageLayout}
|
||||
canEdit={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Items table */}
|
||||
<CatalogItemsTable
|
||||
items={itemsData.items}
|
||||
pageId={page.id}
|
||||
pageLayout={page.pageLayout}
|
||||
canEdit={true}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Translate tab ─────────────────────────────── */}
|
||||
<TabsContent value="translate" className="flex-1 overflow-y-auto px-4 pb-4 mt-2">
|
||||
{itemsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : itemsData ? (
|
||||
<CatalogTranslateTab
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
canEdit={true}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle row (inline) ─────────────────────────────────────
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={id} className="text-xs font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user