revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s

revert style: clean up code with prettier and eslint
This commit is contained in:
remco committed 2026-07-12 21:02:03 +02:00
1 parent 27de078f54
commit e85e4d74ea
378 files changed
+20710 -22428

No files matched your search

@@ -1,57 +1,63 @@
"use client";
'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";
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;
lineNumber: number
raw: string
ok: boolean
error?: string
baseId?: number
credits?: number
points?: number
pointsType?: number
}
interface BulkImportItemsProps {
pageId: number;
onImported?: () => void;
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 [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[] = [];
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);
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;
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;
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: false, error: 'Invalid number' })
return
}
out.push({
lineNumber: idx + 1,
@@ -61,29 +67,29 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
credits,
points,
pointsType,
});
});
return out;
}, [input]);
})
})
return out
}, [input])
const validCount = parsed.filter((r) => r.ok).length;
const invalidCount = parsed.length - validCount;
const validCount = parsed.filter((r) => r.ok).length
const invalidCount = parsed.length - validCount
async function handleImport() {
const valid = parsed.filter((r) => r.ok);
const valid = parsed.filter((r) => r.ok)
if (valid.length === 0) {
toast.error("No valid rows to import");
return;
toast.error('No valid rows to import')
return
}
setImporting(true);
let success = 0;
let failed = 0;
setImporting(true)
let success = 0
let failed = 0
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: "",
catalogName: '',
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
@@ -93,24 +99,24 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
if (res.ok) success++;
else failed++;
haveOffer: '1',
clubOnly: '0',
extradata: '',
})
if (res.ok) success++
else failed++
} catch {
failed++;
failed++
}
}
setImporting(false);
setImporting(false)
if (success > 0) {
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`);
setInput("");
setOpen(false);
onImported?.();
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ''}.`)
setInput('')
setOpen(false)
onImported?.()
} else {
toast.error(`All ${failed} imports failed.`);
toast.error(`All ${failed} imports failed.`)
}
}
@@ -138,9 +144,10 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
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.
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>
@@ -172,12 +179,14 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
</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>
<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-[var(--admin-success)]">OK</span>
@@ -203,11 +212,11 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? "s" : ""}
Import {validCount} item{validCount !== 1 ? 's' : ''}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
)
}
@@ -1,102 +1,108 @@
"use client";
'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";
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;
value: string
onChange: (name: string) => void
/** Filter: 'header' | 'teaser' | 'all' */
type?: "header" | "teaser" | "all";
disabled?: boolean;
label?: string;
type?: 'header' | 'teaser' | 'all'
disabled?: boolean
label?: string
}
const PAGE_SIZE = 80;
const PAGE_SIZE = 80
export function CatalogImagePicker({
value,
onChange,
type = "all",
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);
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 t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
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);
})
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);
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]);
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;
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false);
fetchImages(offset, false)
}
}, [loading, hasMore, offset, fetchImages]);
}, [loading, hasMore, offset, fetchImages])
const handleSelect = (name: string) => {
onChange(name);
setOpen(false);
};
onChange(name)
setOpen(false)
}
const handleClear = () => {
onChange("");
setOpen(false);
};
onChange('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -116,20 +122,27 @@ export function CatalogImagePicker({
)}
<div className="min-w-0 text-left">
<p className="text-xs font-medium truncate">
{label || (type === "header" ? "Headline image" : type === "teaser" ? "Teaser image" : "Image")}
{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>
<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"}
{type === 'header'
? 'Select Headline Image'
: type === 'teaser'
? 'Select Teaser Image'
: 'Select Catalog Image'}
</DialogTitle>
</DialogHeader>
@@ -146,7 +159,7 @@ export function CatalogImagePicker({
{search && (
<button
type="button"
onClick={() => setSearch("")}
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" />
@@ -176,8 +189,8 @@ export function CatalogImagePicker({
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",
'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}
>
@@ -200,17 +213,17 @@ export function CatalogImagePicker({
</div>
</DialogContent>
</Dialog>
);
)
}
// ── Thumbnail with gif/png fallback ─────────────────────────────
function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number }) {
const [error, setError] = useState(0);
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]);
useEffect(() => setError(0), [name])
if (!name || error >= 2) {
return (
@@ -220,19 +233,20 @@ function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number })
>
<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`;
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 }}
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size * 0.75 }}
onError={() => setError((e) => e + 1)}
/>
);
)
}
+80 -67
View File
@@ -1,103 +1,114 @@
"use client";
'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";
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;
value: number
onChange: (iconImage: number) => void
disabled?: boolean
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode;
children?: React.ReactNode
}
const PAGE_SIZE = 120;
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);
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 t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
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);
})
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);
console.error('[IconPicker]', err)
} finally {
setLoading(false);
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]);
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;
const el = scrollRef.current
if (!el || loading || !hasMore) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false);
fetchIcons(offset, false)
}
}, [loading, hasMore, offset, fetchIcons]);
}, [loading, hasMore, offset, fetchIcons])
const handleSelect = (id: number) => {
onChange(id);
setOpen(false);
};
onChange(id)
setOpen(false)
}
const handleClear = () => {
onChange(0);
setOpen(false);
};
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">
<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"}
{value > 0 ? `#${value}` : 'None'}
</span>
</Button>
)}
@@ -120,7 +131,7 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
{search && (
<button
type="button"
onClick={() => setSearch("")}
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" />
@@ -151,8 +162,8 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
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",
'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}`}
>
@@ -171,18 +182,20 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
</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);
const [error, setError] = useState(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage]);
useEffect(() => setError(false), [iconImage])
if (error || iconImage <= 0) {
return <Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />;
return (
<Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />
)
}
return (
@@ -192,8 +205,8 @@ function IconPreview({ iconImage, size = 24 }: { iconImage: number; size?: numbe
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
);
)
}
@@ -1,18 +1,18 @@
"use client";
'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";
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;
items: CatalogItemData[]
furniRevisionMap: Record<number, { classname: string; revision: number }>
layout: string
canEdit: boolean
onItemClick?: (item: CatalogItemData) => void
}
export function ItemsShopPreview({
@@ -28,11 +28,11 @@ export function ItemsShopPreview({
<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);
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">
@@ -40,13 +40,13 @@ export function ItemsShopPreview({
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn("grid gap-2", gridClass)}>
<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;
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
@@ -55,9 +55,9 @@ export function ItemsShopPreview({
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",
'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}
>
@@ -67,7 +67,7 @@ export function ItemsShopPreview({
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ""}
classname={furni?.classname ?? ''}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
@@ -125,71 +125,82 @@ export function ItemsShopPreview({
</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;
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";
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";
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;
case 'frontpage':
case 'single_bundle':
case 'room_bundle':
return 140
case 'spaces_new':
return 110
default:
return 96;
return 96
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({ classname, revision, size }: { classname: string; revision: number; size: number }) {
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 }} />
);
<ImageIcon
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
)
}
const primary = getLocalIconUrl(classname);
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = "0";
img.style.opacity = '0'
}
}
@@ -199,9 +210,9 @@ function ShopFurniIcon({ classname, revision, size }: { classname: string; revis
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
);
)
}
@@ -1,79 +1,85 @@
"use client";
'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";
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;
id: number
itemName: string
publicName: string
type: string
spriteId: number
}
interface QuickAddFurniProps {
pageId: number;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
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);
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);
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);
}, []);
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
const t = setTimeout(() => setDebounced(query.trim()), 300)
return () => clearTimeout(t)
}, [query])
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50);
}, [open]);
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([]);
return;
setResults([])
return
}
setLoading(true);
const ac = new AbortController();
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]);
.finally(() => setLoading(false))
return () => ac.abort()
}, [debounced, open])
const handleAdd = useCallback(
(base: FurniResult) => {
@@ -92,21 +98,21 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
haveOffer: '1',
clubOnly: '0',
extradata: '',
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: "Failed to add item.",
errorMessage: 'Failed to add item.',
onSuccess: () => {
toast.success(`Added #${base.id}`);
toast.success(`Added #${base.id}`)
},
},
);
)
},
[pageId, run],
);
)
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -114,7 +120,9 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
<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>
<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">
@@ -148,7 +156,7 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id];
const furni = furniRevisionMap[r.id]
return (
<button
key={r.id}
@@ -157,10 +165,15 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
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} />
<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>}
{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}
@@ -168,14 +181,14 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
</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 }) {
@@ -184,17 +197,17 @@ function FurniThumb({ classname, revision }: { classname: string; revision: numb
<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);
const primary = getLocalIconUrl(classname)
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const img = e.target as HTMLImageElement
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
} else {
img.style.opacity = "0";
img.style.opacity = '0'
}
}
@@ -205,10 +218,10 @@ function FurniThumb({ classname, revision }: { classname: string; revision: numb
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
style={{ imageRendering: 'pixelated' }}
onError={handleError}
loading="lazy"
/>
</div>
);
)
}