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

@@ -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>
)
}