Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { ChevronRight, Home } from "lucide-react";
import { Fragment } from "react";
import Link from "@/components/link";
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>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
import { findAdminHub } from "@/lib/admin-nav";
/**
* When the current path belongs to a hub, wraps children with a shared
* title + tab strip. Dashboard and unknown routes render children as-is.
*/
export function AdminHubChrome({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const t = useTranslations("pages.admin.hubs");
if (!hub) return <>{children}</>;
const tabs = hub.tabs.map((tab) => ({
href: tab.href,
label: t(`tabs.${tab.labelKey}`),
match: tab.match,
group: tab.group,
}));
return (
<AdminPageShell
icon={hub.icon}
title={t(hub.titleKey)}
subtitle={t(hub.subtitleKey)}
tabs={tabs.length > 1 ? <AdminSectionTabs tabs={tabs} /> : undefined}
className="admin-hub-panel"
>
{children}
</AdminPageShell>
);
}
@@ -0,0 +1,101 @@
"use client";
import { Menu, X } from "lucide-react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
const AdminMobileContext = createContext<{ close: () => void }>({
close: () => {},
});
export function useAdminMobile() {
return useContext(AdminMobileContext);
}
export function AdminMobileWrapper({
sidebar,
children,
}: {
sidebar: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
const mobile = useMemo(() => ({ close }), [close]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
return (
<AdminMobileContext.Provider value={mobile}>
{/* Wider than public max-w-7xl (80rem), capped so it never goes full-bleed. */}
<div className="min-h-screen bg-[var(--admin-canvas)]">
<div className="mx-auto flex min-h-screen w-full max-w-full min-w-0 flex-col lg:max-w-[96rem] lg:flex-row lg:border-x lg:shadow-xl border-[var(--admin-border)]">
{/* Mobile header */}
<div
className={`lg:hidden sticky top-0 flex items-center gap-3 px-4 py-3 border-b ${
open ? "z-30" : "z-50"
}`}
style={{
backgroundColor: "var(--admin-sidebar-background)",
borderColor: "var(--admin-border)",
}}
>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-center w-9 h-9 rounded-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
style={{ color: "var(--admin-sidebar-text-readable)" }}
>
{open ? <X size={18} /> : <Menu size={18} />}
</button>
<span
className="text-sm font-bold"
style={{ color: "var(--admin-sidebar-text-readable)" }}
>
Admin Panel
</span>
</div>
{/* Backdrop */}
{open && (
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-40 lg:hidden"
onClick={() => setOpen(false)}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
)}
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:!min-h-screen lg:!translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
style={{ transition: "transform 300ms ease-in-out" }}
>
{sidebar}
</div>
{/* Content */}
<div className="flex flex-col flex-1 min-w-0">{children}</div>
</div>
</div>
</AdminMobileContext.Provider>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useAdminMobile } from "@/components/admin/admin-mobile-wrapper";
import Link from "@/components/link";
/**
* Sidebar link that highlights when its route is active.
* Optional matchPrefixes cover multi-page hubs (e.g. Settings → theme/vpn).
*/
export function AdminNavLink({
href,
icon,
matchPrefixes,
matchExcludePrefixes,
children,
}: {
href: string;
icon?: ReactNode;
matchPrefixes?: string[];
matchExcludePrefixes?: string[];
children: ReactNode;
}) {
const pathname = usePathname() ?? "";
const { close } = useAdminMobile();
const excluded = matchExcludePrefixes?.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
let active: boolean;
if (excluded) {
active = false;
} else if (href === "/admin") {
active = pathname === "/admin";
} else if (matchPrefixes?.length) {
active = matchPrefixes.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
} else {
active = pathname === href || pathname.startsWith(`${href}/`);
}
return (
<Link
href={href}
onClick={close}
aria-current={active ? "page" : undefined}
className={`group flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm no-underline transition-colors duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)] ${
active
? "bg-[var(--admin-accent)]/25 font-semibold text-[var(--admin-sidebar-text-readable)] border-l-2 border-[var(--admin-accent)] shadow-sm"
: "font-medium text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 hover:no-underline"
}`}
>
{icon ? (
<span
className={`flex-none transition-colors ${
active
? "text-[var(--admin-accent)]"
: "text-[var(--admin-sidebar-text-readable)] opacity-75 group-hover:opacity-100"
}`}
>
{icon}
</span>
) : null}
<span className="truncate">{children}</span>
</Link>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
interface AdminPageShellProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
actions?: ReactNode;
tabs?: ReactNode;
children: ReactNode;
className?: string;
}
/** Shared admin page chrome: icon title, optional actions/tabs, then body. */
export function AdminPageShell({
icon: Icon,
title,
subtitle,
actions,
tabs,
children,
className = "",
}: AdminPageShellProps) {
return (
<div className={`space-y-5 ${className}`.trim()}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
{Icon ? (
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center shrink-0">
<Icon size={20} className="text-[var(--admin-accent)]" />
</div>
) : null}
<div className="min-w-0">
<h1 className="m-0 text-2xl font-extrabold tracking-tight text-[var(--admin-text)]">
{title}
</h1>
{subtitle ? (
<p className="m-0 mt-0.5 text-sm text-[var(--admin-text-muted)]">
{subtitle}
</p>
) : null}
</div>
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</div>
{tabs}
<div className="min-w-0">{children}</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "@/components/link";
import { cn } from "@/lib/utils";
export interface AdminTab {
href: string;
label: string;
/** Extra prefixes that count as active for this tab (detail routes). */
match?: string[];
/** Optional group id for multi-row strips (e.g. Radio primary/tools). */
group?: string;
}
function matchLength(pathname: string, prefix: string): number {
if (pathname === prefix) return prefix.length + 1; // prefer exact
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
return -1;
}
function TabRow({
tabs,
bestIndex,
globalOffset,
label,
}: {
tabs: AdminTab[];
bestIndex: number;
globalOffset: number;
label?: string;
}) {
return (
<div className="min-w-0">
{label ? (
<div className="px-1 pb-1 text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</div>
) : null}
<nav
className="flex gap-1 overflow-x-auto border-b border-[var(--admin-border)] -mx-1 px-1"
aria-label={label ? `Section ${label}` : "Section"}
>
{tabs.map((tab, index) => {
const active = globalOffset + index === bestIndex;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-px shrink-0 rounded-t-lg border-b-2 px-3.5 py-2.5 text-sm font-semibold no-underline transition-colors",
active
? "border-[var(--admin-accent)] text-[var(--admin-text)] bg-[var(--admin-accent)]/8"
: "border-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/5",
)}
>
{tab.label}
</Link>
);
})}
</nav>
</div>
);
}
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
const pathname = usePathname() ?? "";
const t = useTranslations("pages.admin.hubs");
let bestIndex = -1;
let bestScore = -1;
tabs.forEach((tab, index) => {
const prefixes = tab.match ?? [tab.href];
for (const prefix of prefixes) {
const score = matchLength(pathname, prefix);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
}
});
const hasGroups = tabs.some((t) => t.group);
if (!hasGroups) {
return <TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />;
}
const order: string[] = [];
for (const tab of tabs) {
const g = tab.group ?? "primary";
if (!order.includes(g)) order.push(g);
}
let offset = 0;
return (
<div className="space-y-3">
{order.map((groupId) => {
const groupTabs = tabs.filter(
(tab) => (tab.group ?? "primary") === groupId,
);
const rowOffset = offset;
offset += groupTabs.length;
const label = groupId === "tools" ? t("tabs.toolsGroup") : undefined;
return (
<TabRow
key={groupId}
tabs={groupTabs}
bestIndex={bestIndex}
globalOffset={rowOffset}
label={label}
/>
);
})}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import {
ADMIN_NAV_GROUPS,
type AdminNavItem,
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "admin-nav-collapsed";
function itemIsActive(pathname: string, item: AdminNavItem): boolean {
if (
item.matchExcludePrefixes?.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
)
) {
return false;
}
if (item.href === "/admin") return pathname === "/admin";
if (item.matchPrefixes?.length) {
return item.matchPrefixes.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
}
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
function groupHasActive(pathname: string, items: AdminNavItem[]): boolean {
return items.some((item) => itemIsActive(pathname, item));
}
function readCollapsed(): Record<string, boolean> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return {};
return parsed as Record<string, boolean>;
} catch {
return {};
}
}
function persistCollapsed(next: Record<string, boolean>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function AdminSidebarNav({
allowedPermissions = [],
isSuperAdmin = false,
navConfig = {},
}: {
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
allowedPermissions?: string[];
isSuperAdmin?: boolean;
/** Owner overlay: hide + reorder groups/items (from website_settings). */
navConfig?: AdminNavConfig;
}) {
const t = useTranslations("pages.admin.nav");
const pathname = usePathname() ?? "";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [hydrated, setHydrated] = useState(false);
const allowed = useMemo(
() => new Set(allowedPermissions),
[allowedPermissions],
);
const groups = useMemo(() => {
const access = {
isSuperAdmin,
has: (slug: string) => allowed.has(slug),
};
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
.map((group) => ({
...group,
items: group.items.filter((item) => navItemIsAllowed(item, access)),
}))
.filter((group) => group.items.length > 0);
}, [allowed, isSuperAdmin, navConfig]);
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
useEffect(() => {
setCollapsed(readCollapsed());
setHydrated(true);
}, []);
// Keep the group that contains the current page open.
useEffect(() => {
if (!hydrated) return;
const activeKeys = groups
.filter((g) => groupHasActive(pathname, g.items))
.map((g) => g.labelKey);
if (activeKeys.length === 0) return;
setCollapsed((prev) => {
let changed = false;
const next = { ...prev };
for (const key of activeKeys) {
if (next[key]) {
next[key] = false;
changed = true;
}
}
if (!changed) return prev;
persistCollapsed(next);
return next;
});
}, [pathname, hydrated, groups]);
const toggle = useCallback((labelKey: string) => {
setCollapsed((prev) => {
const next = { ...prev, [labelKey]: !prev[labelKey] };
persistCollapsed(next);
return next;
});
}, []);
const expandAll = useCallback(() => {
const next: Record<string, boolean> = {};
for (const key of allKeys) next[key] = false;
persistCollapsed(next);
setCollapsed(next);
}, [allKeys]);
const collapseAll = useCallback(() => {
const next: Record<string, boolean> = {};
for (const key of allKeys) next[key] = true;
// Keep the active route's group open so you never lose context.
for (const g of groups) {
if (groupHasActive(pathname, g.items)) next[g.labelKey] = false;
}
persistCollapsed(next);
setCollapsed(next);
}, [allKeys, groups, pathname]);
const allExpanded = hydrated && allKeys.every((k) => !collapsed[k]);
const canCollapseMore =
hydrated &&
allKeys.some((k) => {
const g = groups.find((x) => x.labelKey === k);
if (g && groupHasActive(pathname, g.items)) return false;
return !collapsed[k];
});
return (
<nav
className="flex min-h-0 flex-1 flex-col overflow-hidden"
aria-label={t("overview")}
>
<div className="flex shrink-0 items-center gap-1 border-b border-[var(--admin-border)] px-3 py-2">
<button
type="button"
onClick={expandAll}
disabled={allExpanded}
title={t("expandAll")}
aria-label={t("expandAll")}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5",
"text-[0.65rem] font-bold uppercase tracking-wide",
"text-[var(--admin-sidebar-muted-readable)]",
"hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"disabled:pointer-events-none disabled:opacity-40",
"transition-colors",
)}
>
<ChevronsUpDown size={14} aria-hidden />
<span className="truncate">{t("expandAll")}</span>
</button>
<button
type="button"
onClick={collapseAll}
disabled={!canCollapseMore}
title={t("collapseAll")}
aria-label={t("collapseAll")}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5",
"text-[0.65rem] font-bold uppercase tracking-wide",
"text-[var(--admin-sidebar-muted-readable)]",
"hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"disabled:pointer-events-none disabled:opacity-40",
"transition-colors",
)}
>
<ChevronsDownUp size={14} aria-hidden />
<span className="truncate">{t("collapseAll")}</span>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
{groups.map((group, gi) => {
const GroupIcon = group.icon;
const isCollapsed = Boolean(collapsed[group.labelKey]);
const panelId = `admin-nav-${group.labelKey}`;
return (
<div
key={group.labelKey}
className={
gi > 0 ? "mt-3 border-t border-[var(--admin-border)] pt-3" : ""
}
>
<button
type="button"
onClick={() => toggle(group.labelKey)}
aria-expanded={!isCollapsed}
aria-controls={panelId}
className={cn(
"flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left",
"text-[0.65rem] font-extrabold uppercase tracking-[0.14em]",
"text-[var(--admin-sidebar-text-readable)] opacity-70",
"hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"transition-colors",
)}
>
<GroupIcon size={14} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">
{t(group.labelKey)}
</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 opacity-80 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
aria-hidden
/>
</button>
<div
id={panelId}
hidden={isCollapsed}
className="mt-1 space-y-0.5"
>
{group.items.map((item) => {
const ItemIcon = item.icon;
return (
<AdminNavLink
key={`${group.labelKey}-${item.href}`}
href={item.href}
matchPrefixes={item.matchPrefixes}
matchExcludePrefixes={item.matchExcludePrefixes}
icon={<ItemIcon size={16} />}
>
{t(item.labelKey)}
</AdminNavLink>
);
})}
</div>
</div>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,52 @@
import Link from "@/components/link";
/** Minimal prev/next pager for admin list pages without DataTable. */
export function AdminSimplePager({
page,
lastPage,
total,
basePath,
params,
label,
}: {
page: number;
lastPage: number;
total: number;
basePath: string;
/** Extra query params preserved across pages (e.g. filter=sold). */
params?: Record<string, string>;
label?: string;
}) {
if (lastPage <= 1) return null;
const href = (p: number) => {
const u = new URLSearchParams(params);
u.set("page", String(p));
return `${basePath}?${u.toString()}`;
};
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-sm text-[var(--admin-text-muted)]">
<span>
{label ? `${label} · ` : null}
{total} total · page {page}/{lastPage}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link className="btn btn-ghost btn-sm" href={href(page - 1)}>
Previous
</Link>
) : (
<span className="opacity-40">Previous</span>
)}
{page < lastPage ? (
<Link className="btn btn-ghost btn-sm" href={href(page + 1)}>
Next
</Link>
) : (
<span className="opacity-40">Next</span>
)}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
export function AdminTopbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs");
const pathname = usePathname() ?? "";
const hub = findAdminHub(pathname);
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{tNav("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{tNav("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { deleteArticle } from "@/actions/admin-articles";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
export function ArticleCard({
id,
title,
image,
createdAt,
author,
}: {
id: string;
title: string;
image: string;
createdAt: string | null;
author?: string;
}) {
const t = useTranslations("pages.admin.articles");
const router = useRouter();
const [pending, startTransition] = useTransition();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
async function handleDelete() {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete", { title }),
confirmLabel: t("delete"),
variant: "danger",
});
if (!ok) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
await deleteArticle(fd);
router.refresh();
});
}
return (
<div className="admin-card overflow-hidden flex flex-col">
{confirmDialog}
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image || ""}
alt={title}
className="w-full h-full object-cover block"
onError={(e) => {
e.currentTarget.style.visibility = "hidden";
}}
/>
</div>
<div className="p-3 flex flex-col flex-1">
<Link
href={`/admin/articles/${id}`}
className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
style={{ textDecoration: "none" }}
>
{title}
</Link>
<p className="text-xs text-[var(--admin-text-muted)] mt-1 mb-3 flex items-center gap-2 flex-wrap">
<span>{createdAt ? createdAt.slice(0, 10) : "—"}</span>
{author ? (
<>
<span>·</span>
<span>{author}</span>
</>
) : null}
</p>
<div className="flex gap-2 mt-auto">
<Link
href={`/admin/articles/${id}`}
className="btn btn-primary flex-1 text-center"
>
{t("edit")}
</Link>
<button
type="button"
className="btn btn-danger"
disabled={pending}
onClick={handleDelete}
>
{t("delete")}
</button>
</div>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { slugify } from "@/lib/format";
import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text";
export function ArticleForm({
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<void>;
edit?: boolean;
defaultValues?: {
title?: string;
slug?: string;
image?: string;
shortStory?: string;
fullStory?: string;
};
}) {
const t = useTranslations("pages.admin.articles.form");
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const suggestedSlug = useMemo(() => slugify(title), [title]);
return (
<form
action={action}
className="admin-card"
style={{ display: "grid", gap: "1rem" }}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("title")}
</span>
<input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered flex-1"
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</div>
<button type="submit" className="btn btn-primary justify-self-start">
{t("save")}
</button>
</form>
);
}
@@ -0,0 +1,63 @@
"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, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}${catQs}`, {
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, catQs]);
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,305 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
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 { type CatalogStatsTotals, StatsBar } from "./stats-bar";
import {
type CatalogManagerMode,
CatalogManagerProvider,
catalogTreeQs,
useCatalogManager,
} from "./use-catalog-store";
/**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
*/
export function CatalogManagerDialog({
initialRoots = [],
catalogType = "normal",
canEdit = false,
totals = null,
}: {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open]);
const title = catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
return (
<>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setOpen(true)}
>
<LayoutGrid className="h-4 w-4" />
{title}
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open={open}
onOpenChange={setOpen}
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</div>
</div>,
document.body,
)
: null}
</>
);
}
function CatalogManagerInner({
open,
onOpenChange,
initialRoots,
totals,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
id: Number(p.id),
caption: String(p.caption ?? ""),
iconImage: Number(p.iconImage) || 0,
enabled: String(p.enabled ?? "0"),
orderNum: Number(p.orderNum) || 0,
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
const activeStillValid =
state.activeTabId !== null &&
pages.some((p) => p.id === state.activeTabId);
if (pages.length > 0 && !activeStillValid) {
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, catQs]);
// Seed from SSR immediately, then refresh from API.
useEffect(() => {
if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
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 });
};
const handleCreateRoot = async () => {
if (!canEdit) return;
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result =
catalogType === "bc"
? await createBcPage({
parentId: -1,
caption: caption.trim(),
})
: 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) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
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 });
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 });
}
};
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
: "Visual Catalog Manager";
return (
<TooltipProvider delayDuration={300}>
<div
className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] px-4 py-2.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
<h2
id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none"
>
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
<BreadcrumbBar />
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
<div
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
</div>
<StatsBar totals={totals} />
</TooltipProvider>
);
}
@@ -35,7 +35,8 @@ export function ImagePreview({
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFallback(false);
setSrc(`${basePath}/${value.trim()}.png`);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
@@ -46,6 +47,7 @@ export function ImagePreview({
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
@@ -67,11 +69,11 @@ export function ImagePreview({
return (
<div
className={`relative flex items-center justify-center overflow-hidden rounded border bg-muted/30 ${className || ""}`}
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ""}`}
style={{ height }}
>
{status === "loading" && (
<Loader2 className="absolute h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
@@ -0,0 +1,775 @@
"use client";
import {
AlertCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Languages,
Loader2,
Package,
Save,
Settings,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
import {
type CatalogItemData,
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 Link from "@/components/link";
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 { catalogTreeQs, 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 }[];
gamedataHotel?: 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, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
catalogType === "bc"
? `/admin/catalog/builder-club/${pageId}`
: `/admin/catalog/${pageId}`;
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 (canEdit && isDirty && !saving && page) handleSave();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
}, [canEdit, isDirty, saving, page, handleSave]);
const loadPage = useCallback(
async (id: number) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
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 ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
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);
}
},
[catQs],
);
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 (shop catalog only — BC items live on the detail page)
useEffect(() => {
if (catalogType === "bc") return;
if (
(activeTab === "items" || activeTab === "translate") &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
pageId > 0
) {
loadItemsData(pageId);
}
}, [
activeTab,
itemsLoaded,
itemsLoading,
pageId,
loadItemsData,
catalogType,
]);
function update(key: string, value: string | number | null) {
if (!canEdit) return;
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
if (!page || !canEdit) return;
setSaving(true);
try {
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: 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-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<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={pageHref} 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-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] 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 || !canEdit}
>
{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-[var(--admin-warning)]" />
)}
</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-[var(--admin-warning)] text-[var(--admin-warning-foreground)] 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 || !canEdit}
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"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>BC items are managed on the full Builder Club page editor.</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : 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">
{canEdit && (
<>
<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={canEdit}
/>
)}
{/* Items table */}
<CatalogItemsTable
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={canEdit}
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"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : 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={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
gamedataHotel={itemsData.gamedataHotel}
/>
) : 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,313 @@
"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 {
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
} from "@/actions/catalog-bc";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
export type { RootTab } from "@/types/catalog";
import type { RootTab } from "@/types/catalog";
interface RootTabsProps {
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void | Promise<void>;
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
}
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
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;
if (!canEdit) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorder({ 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, canEdit, catalogType],
);
const handleToggle = async (tab: RootTab) => {
if (!canEdit) return;
try {
if (catalogType === "bc") {
await toggleBcPage({ id: tab.id, field: "enabled" });
} else {
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 (!canEdit) return;
const ok = await confirm({
title: "Delete root page",
description: `Delete root page "${tab.caption}"? Child pages (if any) will be moved to root; items on this page will be deleted.`,
confirmLabel: "Delete",
});
if (!ok) return;
try {
if (catalogType === "bc") {
await deleteBcTreePage({ pageId: tab.id, mode: "reparent" });
} else {
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 shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] px-2 py-1.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{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>
{canEdit && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
title="New root page"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</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>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,70 @@
"use client";
import { FolderTree, Keyboard, Package, ToggleRight } from "lucide-react";
import { useCatalogManager } from "./use-catalog-store";
export interface CatalogStatsTotals {
totalPages: number;
enabledPages: number;
totalItems: number;
}
export function StatsBar({ totals }: { totals?: CatalogStatsTotals | null }) {
const { state } = useCatalogManager();
const selected = state.multiSelectedIds.size;
const rootTabs = state.rootPages.length;
const pages = totals?.totalPages ?? state.nodes.size;
const enabled = totals?.enabledPages ?? null;
const items = totals?.totalItems ?? null;
const loadedOnly = !totals;
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" />
{rootTabs} root
</span>
<span>
{pages} pages
{loadedOnly ? " loaded" : ""}
</span>
{enabled !== null && (
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{enabled} enabled
</span>
)}
{items !== null && (
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{items} items
</span>
)}
{selected > 0 && (
<span className="text-primary font-medium">{selected} 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,336 @@
"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-[var(--admin-focus-ring)]/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,361 @@
"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_FULL_TREE"; 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_FULL_TREE": {
const nodes = new Map<number, TreeNodeData>();
const childrenMap = new Map<number, number[]>();
for (const raw of action.nodes) {
const id = Number(raw.id);
const parentId = Number(raw.parentId);
if (!Number.isFinite(id)) continue;
const n: TreeNodeData = {
...raw,
id,
parentId: Number.isFinite(parentId) ? parentId : -1,
enabled: String(raw.enabled ?? "0"),
visible: String(raw.visible ?? "0"),
childCount: Number(raw.childCount) || 0,
itemCount: Number(raw.itemCount) || 0,
iconImage: Number(raw.iconImage) || 0,
iconColor: Number(raw.iconColor) || 0,
orderNum: Number(raw.orderNum) || 0,
};
nodes.set(id, n);
const siblings = childrenMap.get(n.parentId) || [];
siblings.push(id);
childrenMap.set(n.parentId, siblings);
}
// Roots may be parent_id -1 or 0 — mirror both keys so tabs resolve children.
const underNeg1 = childrenMap.get(-1) || [];
const under0 = childrenMap.get(0) || [];
if (underNeg1.length || under0.length) {
const merged = [...new Set([...underNeg1, ...under0])];
childrenMap.set(-1, merged);
childrenMap.set(0, merged);
}
for (const [parentId, ids] of childrenMap) {
ids.sort(
(a, b) =>
(nodes.get(a)?.orderNum ?? 0) - (nodes.get(b)?.orderNum ?? 0),
);
childrenMap.set(parentId, ids);
}
return { ...state, nodes, childrenMap };
}
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 ───────────────────────────────────────────────────
export type CatalogManagerMode = "normal" | "bc";
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
catalogType: CatalogManagerMode;
canEdit: boolean;
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
null,
);
export function CatalogManagerProvider({
children,
catalogType = "normal",
canEdit = false,
}: {
children: ReactNode;
catalogType?: CatalogManagerMode;
canEdit?: boolean;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider
value={{ state, dispatch, catalogType, canEdit }}
>
{children}
</CatalogManagerContext.Provider>
);
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext);
if (!ctx)
throw new Error(
"useCatalogManager must be used within CatalogManagerProvider",
);
return ctx;
}
/** Append to tree API query strings when in Builder Club mode. */
export function catalogTreeQs(catalogType: CatalogManagerMode): string {
return catalogType === "bc" ? "&catalog=bc" : "";
}
+919
View File
@@ -0,0 +1,919 @@
"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 { useRouter } from "next/navigation";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
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 { adminFetch } from "@/lib/admin-fetch";
import { catalogueIconUrl } from "@/lib/catalog-assets";
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={catalogueIconUrl(iconImage)}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
onError={() => setError(true)}
/>
);
}
/* ─── Layout label helper ────────────────────────────────── */
export const LAYOUT_COLORS: Record<string, string> = {
default_3x3:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
frontpage:
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
spaces_new:
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
pets: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets2: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets3: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
marketplace: "bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]",
recycler:
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
club_buy:
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
single_bundle:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
room_bundle:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
};
/* ─── Highlight matched text ─────────────────────────────── */
function HighlightText({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}
/* ─── Main Tree Component ────────────────────────────────── */
type FilterMode = "all" | "enabled" | "disabled";
export function CatalogTree({
rootPages,
catalogType = "normal",
}: CatalogTreeProps) {
const router = useRouter();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
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
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 adminFetch(
`/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, catalogType]);
// 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 adminFetch("/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 adminFetch("/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.` : "";
const ok = await confirm({
title: "Delete page",
description: `Delete "${node.caption}"?${childInfo}${itemInfo}`,
confirmLabel: "Delete",
});
if (!ok) return;
try {
const res = await adminFetch(
`/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, confirm],
);
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,
}}
>
{confirmDialog}
<TooltipProvider delayDuration={400}>
<div className="rounded-lg border bg-card shadow-sm">
{/* Header */}
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<FolderTree className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Catalog Structure</h3>
<Badge variant="secondary" className="text-[10px] h-5">
{displayPages.length}
{filterMode !== "all" ? ` / ${unfilteredPages.length}` : ""}{" "}
pages
</Badge>
</div>
<div className="flex items-center gap-1.5">
{/* Status filter */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode("all")}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors",
filterMode === "all"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show all pages</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode("enabled")}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
filterMode === "enabled"
? "bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]"
: "text-muted-foreground hover:text-foreground",
)}
>
<ToggleRight className="h-3 w-3" />
On
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show enabled only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode("disabled")}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
filterMode === "disabled"
? "bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]"
: "text-muted-foreground hover:text-foreground",
)}
>
<ToggleLeft className="h-3 w-3" />
Off
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Show disabled only</p>
</TooltipContent>
</Tooltip>
</div>
{/* Expand / Collapse all */}
{searchResults === null && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setExpandSignal((s) => s + 1)}
>
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Expand all</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setCollapseSignal((s) => s + 1)}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Collapse all</p>
</TooltipContent>
</Tooltip>
</>
)}
{/* Search */}
<div className="relative w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search pages... /"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-sm"
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchQuery("");
searchInputRef.current?.blur();
}
}}
/>
{searching ? (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)
)}
</div>
</div>
</div>
{/* Tree body */}
<div className="p-1.5 max-h-[calc(100vh-280px)] overflow-y-auto">
{searchResults !== null && (
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{searchResults.length} result
{searchResults.length !== 1 ? "s" : ""} for &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,
isSearchResult,
defaultExpanded,
}: {
node: TreeNode;
level: number;
isSearchResult?: boolean;
defaultExpanded?: boolean;
}) {
void isSearchResult;
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;
const loadChildren = useCallback(async () => {
if (loaded || !hasChildren) return;
setLoading(true);
try {
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
const res = await adminFetch(
`/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]);
// Auto-expand root level on mount
useEffect(() => {
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
loadChildren().then(() => setExpanded(true));
}
}, [defaultExpanded, level, hasChildren, loaded, loadChildren]);
// Expand all signal
useEffect(() => {
if (expandSignal > 0 && hasChildren) {
if (!loaded) {
loadChildren().then(() => setExpanded(true));
} else {
setExpanded(true);
}
}
}, [expandSignal, hasChildren, loaded, loadChildren]);
// Collapse all signal
useEffect(() => {
if (collapseSignal > 0) {
setExpanded(false);
}
}, [collapseSignal]);
const handleToggle = () => {
if (!expanded && !loaded) {
loadChildren().then(() => setExpanded(true));
return;
}
setExpanded(!expanded);
};
const isDisabled = node.enabled !== "1";
const isHidden = node.visible !== "1";
const isEmpty = node.itemCount === 0 && node.childCount === 0;
const layoutColor =
LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
const italianName = translateCaption(node.caption);
const displayName = italianName ?? node.caption;
return (
<div>
<ContextMenu>
<ContextMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */}
<div
role="button"
tabIndex={0}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default",
isDisabled && "opacity-40",
isEmpty && !isDisabled && "opacity-60",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onDoubleClick={() =>
router.push(
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
router.push(
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
);
}
}}
>
{/* Expand/collapse toggle */}
{hasChildren ? (
<button
type="button"
onClick={handleToggle}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
expanded && "rotate-90",
)}
/>
)}
</button>
) : (
<span className="h-5 w-5 shrink-0" />
)}
{/* Catalog icon */}
<CatalogIcon iconImage={node.iconImage} />
{/* Caption link */}
<Link
href={
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`
}
className={cn(
"flex-1 min-w-0 hover:text-primary transition-colors",
isEmpty && !isDisabled
? "text-muted-foreground italic"
: "",
)}
>
<span
className={cn(
"text-sm truncate block",
!isEmpty && "font-medium",
)}
>
<HighlightText text={displayName} query={searchQuery} />
</span>
{italianName && (
<span className="text-[10px] text-muted-foreground truncate block leading-tight">
<HighlightText text={node.caption} query={searchQuery} />
</span>
)}
</Link>
{/* Quick toggle enabled on hover */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(node);
}}
className={cn(
"flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity",
isDisabled
? "hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]"
: "hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]",
)}
>
{isDisabled ? (
<Power className="h-3 w-3" />
) : (
<PowerOff className="h-3 w-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{isDisabled ? "Enable" : "Disable"}</p>
</TooltipContent>
</Tooltip>
{/* Quick add subpage */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
router.push(
catalogType === "bc"
? `/admin/catalog?catalog=bc&newPageParent=${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?catalog=bc&newPageParent=${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>
);
}
@@ -1,51 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { type BcPageData, BcPageDetail } from "./bc-manager";
vi.mock("@/actions/catalog-bc", () => ({
createBcItem: vi.fn(),
deleteBcItem: vi.fn(),
updateBcItem: vi.fn(),
updateBcPage: vi.fn(),
}));
vi.mock("@/components/admin/confirm-dialog", () => ({
useConfirmDialog: () => ({ confirm: vi.fn(), dialog: null }),
}));
vi.mock("@/hooks/use-server-action", () => ({
useServerAction: () => ({ isPending: false, run: vi.fn() }),
}));
const page: BcPageData = {
id: 7,
parentId: -1,
caption: "Builder Club",
pageLayout: "default_3x3",
iconColor: 0,
iconImage: 1,
orderNum: 1,
visible: "1",
enabled: "1",
pageHeadline: "",
pageTeaser: "",
pageSpecial: "",
pageText1: "",
pageText2: "",
pageTextDetails: "",
pageTextTeaser: "",
};
describe("BcPageDetail", () => {
it("uses the canonical return route when embedded in Housekeeping", () => {
const markup = renderToStaticMarkup(
<BcPageDetail
page={page}
items={[]}
canEdit={false}
returnHref="/ase/economy/catalog"
/>,
);
expect(markup).toContain('href="/ase/economy/catalog"');
expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
});
});
@@ -1,614 +0,0 @@
"use client";
import {
ArrowLeft,
HardHat,
Loader2,
Package,
Pencil,
Plus,
Save,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createBcItem,
deleteBcItem,
updateBcItem,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
// ── Types ────────────────────────────────────────────────────────────
export interface BcPageData {
id: number;
parentId: number;
caption: string;
pageLayout: string;
iconColor: number;
iconImage: number;
orderNum: number;
visible: string;
enabled: string;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string;
pageText1: string;
pageText2: string;
pageTextDetails: string;
pageTextTeaser: string;
}
export interface BcItemData {
id: number;
pageId: number;
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}
interface BcPageDetailProps {
page: BcPageData;
items: BcItemData[];
canEdit: boolean;
returnHref?: string;
}
// ── Main Component ───────────────────────────────────────────────────
export function BcPageDetail({
page,
items,
canEdit,
returnHref = "/ase/economy/catalog",
}: BcPageDetailProps) {
const { isPending, run } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const formRef = useRef<HTMLFormElement>(null);
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
const [addingItem, setAddingItem] = useState(false);
const [form, setForm] = useState({
caption: page.caption,
orderNum: page.orderNum,
enabled: page.enabled as "0" | "1",
visible: page.visible as "0" | "1",
pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial,
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateBcPage({ id: page.id, ...form }), {
successMessage: "BC page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
return (
<div className="space-y-6">
{confirmDialog}
{/* Header */}
<div className="flex items-center gap-3">
<Link
href={returnHref}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
BC Catalog
</Link>
<span className="text-muted-foreground">/</span>
<div className="flex items-center gap-2">
<HardHat className="h-5 w-5 text-[var(--admin-warning)]" />
<h1 className="text-2xl font-bold tracking-tight">{page.caption}</h1>
<Badge variant="outline" className="font-mono text-xs">
#{page.id}
</Badge>
<Badge variant="secondary" className="font-mono text-xs">
{page.pageLayout}
</Badge>
</div>
</div>
<form ref={formRef} onSubmit={handleSubmit}>
<div className="grid gap-4 lg:grid-cols-3">
{/* ── Left column: form fields ─────────────── */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Page Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={form.caption}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="orderNum">Order</Label>
<Input
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
disabled={!canEdit}
/>
</div>
</div>
<div className="space-y-2">
<Label>Headline</Label>
<Input
value={form.pageHeadline}
onChange={(e) => update("pageHeadline", e.target.value)}
disabled={!canEdit}
placeholder="Headline image path"
/>
</div>
<div className="space-y-2">
<Label>Teaser</Label>
<Input
value={form.pageTeaser}
onChange={(e) => update("pageTeaser", e.target.value)}
disabled={!canEdit}
placeholder="Teaser image path"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Text 1</Label>
<Textarea
value={form.pageText1}
onChange={(e) => update("pageText1", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Text 2</Label>
<Textarea
value={form.pageText2}
onChange={(e) => update("pageText2", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Details</Label>
<Textarea
value={form.pageTextDetails}
onChange={(e) =>
update("pageTextDetails", e.target.value)
}
disabled={!canEdit}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Text Teaser</Label>
<Textarea
value={form.pageTextTeaser}
onChange={(e) => update("pageTextTeaser", e.target.value)}
disabled={!canEdit}
rows={3}
/>
</div>
</div>
<div className="space-y-2">
<Label>Special</Label>
<Textarea
value={form.pageSpecial}
onChange={(e) => update("pageSpecial", e.target.value)}
disabled={!canEdit}
rows={2}
/>
</div>
</CardContent>
</Card>
{/* Items */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Items ({items.length})
</CardTitle>
{canEdit && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAddingItem(true)}
>
<Plus className="h-3 w-3 mr-1" />
Add Item
</Button>
)}
</div>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No items in this page.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">ID</TableHead>
<TableHead className="text-xs">Name</TableHead>
<TableHead className="text-xs">Item IDs</TableHead>
<TableHead className="text-xs">Order</TableHead>
<TableHead className="text-xs">Extra Data</TableHead>
{canEdit && (
<TableHead className="text-xs w-20">
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-xs font-mono">
{item.id}
</TableCell>
<TableCell className="text-xs font-medium">
{item.catalogName}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{item.itemIds}
</TableCell>
<TableCell className="text-xs">
{item.orderNumber}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || "\u2014"}
</TableCell>
{canEdit && (
<TableCell>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setEditingItem(item)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={async () => {
const ok = await confirm({
title: "Delete BC item",
description: `Delete item "${item.catalogName}"?`,
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteBcItem({ id: item.id }), {
successMessage: "Item deleted.",
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* ── Right column: status ─────────────────── */}
<div className="lg:col-span-1">
<Card className="lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<ToggleRow
id="tg-enabled"
label="Enabled"
description="Active in the BC catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in BC navigation"
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
{/* Stats */}
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
<span>
Layout: <span className="font-mono">{page.pageLayout}</span>
</span>
<span>
Parent:{" "}
<span className="font-mono">
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
</span>
</span>
</div>
{/* Actions */}
{canEdit && (
<div className="space-y-2 border-t pt-3">
<Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
<p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</form>
{/* Edit Item Dialog */}
{editingItem && (
<ItemDialog
item={editingItem}
mode="edit"
isPending={isPending}
onClose={() => setEditingItem(null)}
onSubmit={(data) => {
run(() => updateBcItem({ id: editingItem.id, ...data }), {
successMessage: "Item updated.",
onSuccess: () => setEditingItem(null),
});
}}
/>
)}
{/* Add Item Dialog */}
{addingItem && (
<ItemDialog
item={{
id: 0,
pageId: page.id,
itemIds: "",
catalogName: "",
orderNumber: 1,
extradata: "",
}}
mode="add"
isPending={isPending}
onClose={() => setAddingItem(false)}
onSubmit={(data) => {
run(() => createBcItem({ pageId: page.id, ...data }), {
successMessage: "Item created.",
onSuccess: () => setAddingItem(false),
});
}}
/>
)}
</div>
);
}
// ── Toggle row ───────────────────────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
disabled,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
{label}
</Label>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div>
);
}
// ── Item Dialog ──────────────────────────────────────────────────────
function ItemDialog({
item,
mode,
isPending,
onClose,
onSubmit,
}: {
item: BcItemData;
mode: "add" | "edit";
isPending: boolean;
onClose: () => void;
onSubmit: (data: {
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}) => void;
}) {
const [form, setForm] = useState({
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={form.catalogName}
onChange={(e) =>
setForm((f) => ({ ...f, catalogName: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={form.itemIds}
onChange={(e) =>
setForm((f) => ({ ...f, itemIds: e.target.value }))
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Order</Label>
<Input
type="number"
value={form.orderNumber}
onChange={(e) =>
setForm((f) => ({
...f,
orderNumber: parseInt(e.target.value, 10) || 0,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={form.extradata}
onChange={(e) =>
setForm((f) => ({ ...f, extradata: e.target.value }))
}
placeholder="Optional"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => onSubmit(form)}
disabled={isPending || !form.catalogName || !form.itemIds}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === "add" ? "Add Item" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,55 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { fixEverythingAction } from "@/actions/furni-maintenance";
import { Button } from "@/components/ui/button";
/**
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
*/
export function CatalogFixAllButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function run() {
if (
!window.confirm(
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
)
) {
return;
}
setBusy(true);
try {
const res = await fixEverythingAction();
if (res.ok) {
toast.success("Alle reparaties uitgevoerd");
router.refresh();
} else {
toast.error(res.error ?? "Mislukt");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(false);
}
}
return (
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void run()}
title="Alle meubel-tabellen repareren"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{busy ? "Bezig…" : "Fix alles"}
</Button>
);
}
@@ -0,0 +1,330 @@
"use client";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
catalogPageDuplicates: number;
}
export function CatalogMaintenancePanel() {
const [health, setHealth] = useState<Health | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [dedupePages, setDedupePages] = useState(true);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
<Stat
label="Duplicate BC catalog rows"
value={health.catalogBcDuplicates}
hint="same product listed twice in builder club"
/>
<Stat
label="Duplicate catalog pages"
value={health.catalogPageDuplicates}
hint="same caption under the same parent (double links)"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-3 flex items-center gap-2 rounded-md border p-2">
<Checkbox
id="dedupe-pages"
checked={dedupePages}
onCheckedChange={(v) => setDedupePages(v === true)}
/>
<label
htmlFor="dedupe-pages"
className="text-sm text-muted-foreground"
>
Also merge duplicate catalog pages (double links)
</label>
</div>
<Button
variant="destructive"
disabled={busy !== null}
onClick={async () => {
setBusy("all");
try {
const res = await fixEverythingAction({ dedupePages });
if (res.ok) {
toast.success("All repairs applied");
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}}
>
Fix everything now
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -1,52 +0,0 @@
"use client";
import { Package } from "lucide-react";
import { useState } from "react";
import { catalogueIconUrl } from "@/lib/catalog-assets";
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 shrink-0 text-muted-foreground" />;
}
return (
<img
src={catalogueIconUrl(iconImage)}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
onError={() => setError(true)}
/>
);
}
export const LAYOUT_COLORS: Record<string, string> = {
default_3x3:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
frontpage:
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
spaces_new:
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
pets: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets2: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets3: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
marketplace: "bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]",
recycler:
"bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
club_buy:
"bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
single_bundle:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
room_bundle:
"bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
};
@@ -1,128 +0,0 @@
"use client";
import { Languages, Package, Settings } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { type CatalogItemData, CatalogItemsTable } from "./catalog-items-table";
import { CatalogPageForm } from "./catalog-page-form";
import { CatalogTranslateTab } from "./catalog-translate-tab";
interface CatalogPageData {
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;
}
interface BaseItemData {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogDetailTabsProps {
catalogPage: CatalogPageData;
items: CatalogItemData[];
baseItems: BaseItemData[];
catalogNameMap: Record<number, string>;
childPages: { id: number; caption: string; enabled: string }[];
pageId: number;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[];
interactionTypes: string[];
gamedataHotel: string;
}
export function CatalogDetailTabs({
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
gamedataHotel,
}: CatalogDetailTabsProps) {
return (
<Tabs defaultValue="settings">
<TabsList className="overflow-x-auto flex-nowrap w-full">
<TabsTrigger value="settings" className="gap-2">
<Settings className="h-4 w-4" />
Settings
</TabsTrigger>
<TabsTrigger value="items" className="gap-2">
<Package className="h-4 w-4" />
Items ({items.length})
</TabsTrigger>
<TabsTrigger value="translate" className="gap-2">
<Languages className="h-4 w-4" />
Translate ({baseItems.length}){(() => {
const furniSet = new Set(furniDataIdList);
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? (
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
})()}
</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="mt-4">
<CatalogPageForm
catalogPage={catalogPage}
childPages={childPages}
canEdit={canEdit}
allPages={allPages}
/>
</TabsContent>
<TabsContent value="items" className="mt-4">
<CatalogItemsTable
items={items}
pageId={pageId}
pageLayout={catalogPage.pageLayout}
canEdit={canEdit}
interactionTypes={interactionTypes}
furniRevisionMap={furniRevisionMap}
allPages={allPages}
/>
</TabsContent>
<TabsContent value="translate" className="mt-4">
<CatalogTranslateTab
baseItems={baseItems}
catalogNameMap={catalogNameMap}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
gamedataHotel={gamedataHotel}
/>
</TabsContent>
</Tabs>
);
}
@@ -1,10 +0,0 @@
/**
* Re-export barrel for backward compatibility.
* Implementation moved to ./catalog-items-table/ directory.
*/
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from "./catalog-items-table/furni-icon";
export type {
BaseItemData,
CatalogItemData,
} from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -1,281 +0,0 @@
"use client";
import { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// ── Toggle field ──────────────────────────────────────────────
export function ToggleField({
label,
checked,
value,
onChange,
disabled,
}: {
label: string;
/** Boolean checked state */
checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Switch
checked={isChecked}
onCheckedChange={onChange}
disabled={disabled}
size="sm"
/>
</div>
);
}
// ── Interaction Type Combobox ──────────────────────────────────
export function InteractionTypeCombobox({
value,
onChange,
types,
disabled,
}: {
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm"
>
<span className="truncate">{value || "Select type..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or type custom..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue.trim() ? (
<button
type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => {
onChange(inputValue.trim());
setOpen(false);
setInputValue("");
}}
>
Use &quot;{inputValue.trim()}&quot;
</button>
) : (
"No types found."
)}
</CommandEmpty>
<CommandGroup>
{filtered.map((t) => (
<CommandItem
key={t}
value={t}
onSelect={() => {
onChange(t);
setOpen(false);
setInputValue("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === t ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{t}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ── Move Page Combobox with smart suggestions ─────────────────
export function MovePageCombobox({
value,
onChange,
pages,
currentPageId,
suggestedPageIds,
}: {
value: number | null;
onChange: (id: number | null) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
}) {
const [open, setOpen] = useState(false);
const options = useMemo(
() => pages.filter((p) => p.id !== currentPageId),
[pages, currentPageId],
);
const selectedPage = value ? pages.find((p) => p.id === value) : null;
// Build suggested pages list
const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number;
caption: string;
}[];
}, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(
() => new Set(suggested.map((s) => s.id)),
[suggested],
);
const remaining = useMemo(
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selectedPage ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{selectedPage.id}
</span>
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
{suggested.map((page) => (
<CommandItem
key={`s-${page.id}`}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* All pages */}
<CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
>
{remaining.map((page) => (
<CommandItem
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,48 +0,0 @@
"use client";
import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({
classname,
revision,
}: {
classname: string;
revision: number;
}) {
if (!classname) {
return (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
const localUrl = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback;
if (!stage && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.dataset.fallback = "none";
img.style.opacity = "0";
}
}
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
}
@@ -1,99 +0,0 @@
"use client";
import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getCachedSongPickerOptions,
invalidateSongPickerCache,
loadSongPickerOptions,
type SoundtrackOption,
} from "@/lib/client-cache/song-picker-cache";
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
// callsites keep working. New code should import from the lib module directly.
export { invalidateSongPickerCache };
interface Props {
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}
function formatLength(seconds: number): string {
if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function SongPicker({ value, onChange, disabled }: Props) {
const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial);
useEffect(() => {
if (getCachedSongPickerOptions()) return;
let cancelled = false;
loadSongPickerOptions()
.then((items) => {
if (!cancelled) setOptions(items);
})
.catch(() => {
if (!cancelled) setOptions([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<Select
value={String(value)}
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
disabled={disabled || loading}
>
<SelectTrigger>
{loading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
</span>
) : (
<SelectValue placeholder="No song" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
<span className="text-muted-foreground">— No song —</span>
</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
<span className="inline-flex items-center gap-2">
<Music className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{opt.name}</span>
{opt.author && (
<span className="text-xs text-muted-foreground">
— {opt.author}
</span>
)}
{opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">
{formatLength(opt.length)}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -1,87 +0,0 @@
export interface BaseItemData {
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
}
export interface CatalogItemData {
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItemData | null;
}
export interface InlineEdit {
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
}
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
};
export const DEFAULT_NEW_ITEM = {
catalogName: "",
itemIds: "0",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
};
export function getPointsLabel(type: number) {
return (
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
);
}
@@ -1,741 +0,0 @@
"use client";
import {
Check,
ChevronsUpDown,
FileText,
FolderTree,
Loader2,
Package,
Palette,
Save,
Settings,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import {
CatalogIcon,
LAYOUT_COLORS,
} from "@/components/admin/catalog/catalog-visuals";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ImagePreview } from "@/components/admin/catalog/image-preview";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
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 { useServerAction } from "@/hooks/use-server-action";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
interface CatalogPageData {
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;
}
interface CatalogPageFormProps {
catalogPage: CatalogPageData;
childPages: { id: number; caption: string; enabled: string }[];
canEdit: boolean;
allPages: { id: number; caption: string }[];
}
export function CatalogPageForm({
catalogPage,
childPages,
canEdit,
allPages,
}: CatalogPageFormProps) {
const { isPending, run } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState({
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled as "0" | "1",
visible: catalogPage.visible as "0" | "1",
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly as "0" | "1",
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial || "",
pageText1: catalogPage.pageText1 || "",
pageText2: catalogPage.pageText2 || "",
pageTextDetails: catalogPage.pageTextDetails || "",
pageTextTeaser: catalogPage.pageTextTeaser || "",
});
// Ctrl+S / Cmd+S to save
useEffect(() => {
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canEdit]);
function update<K extends keyof typeof form>(
key: K,
value: (typeof form)[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
}
async function handleDelete() {
const childCount = childPages.length;
const description =
childCount > 0
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.`
: "Delete this catalog page and all its items?";
const ok = await confirm({
title: "Delete catalog page",
description,
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteCatalogPage({ id: catalogPage.id }), {
successMessage: "Page deleted.",
redirectTo: "/ase/economy/catalog",
});
}
const layoutColorClass =
LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
return (
<>
{confirmDialog}
<form ref={formRef} onSubmit={handleSubmit}>
<div className="grid gap-4 lg:grid-cols-3">
{/* ── Left column (2/3): tabs ─────────────────────────── */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardContent className="pt-4">
<Tabs defaultValue="general">
<TabsList className="mb-4">
<TabsTrigger value="general" className="gap-2">
<Settings className="h-4 w-4" />
General
</TabsTrigger>
<TabsTrigger value="texts" className="gap-2">
<FileText className="h-4 w-4" />
Texts
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
</TabsTrigger>
</TabsList>
{/* ── General ──────────────────────────────────── */}
<TabsContent value="general" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Input
id="caption"
value={form.caption}
onChange={(e) => update("caption", e.target.value)}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>Parent Page</Label>
<ParentCombobox
value={form.parentId}
onChange={(id) => update("parentId", id)}
pages={allPages}
currentPageId={catalogPage.id}
disabled={!canEdit}
/>
</div>
</div>
<div className="space-y-2">
<Label>Layout</Label>
<LayoutSelect
value={form.pageLayout}
onChange={(v) => update("pageLayout", v)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
{LAYOUT_DESCRIPTIONS[
form.pageLayout as CatalogLayout
] ?? "Custom layout"}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="orderNum">Order</Label>
<Input
id="orderNum"
type="number"
value={form.orderNum}
onChange={(e) =>
update(
"orderNum",
parseInt(e.target.value, 10) || 0,
)
}
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minRank">Min Rank</Label>
<Input
id="minRank"
type="number"
min={1}
max={100}
value={form.minRank}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
disabled={!canEdit}
/>
</div>
</div>
</TabsContent>
{/* ── Texts ────────────────────────────────────── */}
<TabsContent value="texts" className="space-y-4">
<div className="space-y-1.5">
<Label>Headline</Label>
<CatalogImagePicker
value={form.pageHeadline}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
disabled={!canEdit}
/>
<ImagePreview value={form.pageHeadline} height={50} />
</div>
<div className="space-y-1.5">
<Label>Teaser</Label>
<CatalogImagePicker
value={form.pageTeaser}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
disabled={!canEdit}
/>
<ImagePreview value={form.pageTeaser} height={50} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextareaField
id="pageText1"
label="Text 1"
value={form.pageText1}
onChange={(v) => update("pageText1", v)}
disabled={!canEdit}
rows={4}
max={5000}
/>
<TextareaField
id="pageText2"
label="Text 2"
value={form.pageText2}
onChange={(v) => update("pageText2", v)}
disabled={!canEdit}
rows={4}
max={5000}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextareaField
id="pageTextDetails"
label="Details"
value={form.pageTextDetails}
onChange={(v) => update("pageTextDetails", v)}
disabled={!canEdit}
rows={3}
max={5000}
/>
<TextareaField
id="pageTextTeaser"
label="Text Teaser"
value={form.pageTextTeaser}
onChange={(v) => update("pageTextTeaser", v)}
disabled={!canEdit}
rows={3}
max={5000}
/>
</div>
<TextareaField
id="pageSpecial"
label="Special"
value={form.pageSpecial}
onChange={(v) => update("pageSpecial", v)}
disabled={!canEdit}
rows={2}
max={2048}
/>
</TabsContent>
{/* ── Appearance ───────────────────────────────── */}
<TabsContent value="appearance" className="space-y-4">
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex items-center gap-3">
<IconPicker
value={form.iconImage}
onChange={(id) => update("iconImage", id)}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
Icon shown in the catalog tree navigation.
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="iconColor">Icon Color</Label>
<div className="flex items-center gap-2">
<Input
id="iconColor"
type="number"
min={0}
className="w-32"
value={form.iconColor}
onChange={(e) =>
update(
"iconColor",
parseInt(e.target.value, 10) || 0,
)
}
disabled={!canEdit}
/>
<p className="text-xs text-muted-foreground">
Legacy tint value. Leave at default unless you need to
match a specific theme.
</p>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Child pages list */}
{childPages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FolderTree className="h-4 w-4" />
Child Pages ({childPages.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{childPages.map((child) => (
<Link
key={child.id}
href={`/ase/economy/catalog/${child.id}`}
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
>
<span>
{child.caption}
<span className="ml-2 text-muted-foreground font-mono text-xs">
#{child.id}
</span>
</span>
<Badge
variant={
child.enabled === "1" ? "default" : "outline"
}
>
{child.enabled === "1" ? "Enabled" : "Disabled"}
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* ── Right column (1/3): status sticky ────────────────── */}
<div className="lg:col-span-1">
<Card className="lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">Status & Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Preview */}
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
<CatalogIcon iconImage={form.iconImage} size={36} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{form.caption || "Untitled"}
</p>
<Badge
className={cn(
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
layoutColorClass,
)}
>
{form.pageLayout}
</Badge>
</div>
</div>
{/* Toggles */}
<div className="space-y-3">
<ToggleRow
id="tg-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
disabled={!canEdit}
/>
<ToggleRow
id="tg-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
disabled={!canEdit}
/>
</div>
{/* Stats */}
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FolderTree className="h-3.5 w-3.5" />
{childPages.length} sub
</span>
<span className="flex items-center gap-1">
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
</span>
</div>
{/* Actions */}
{canEdit && (
<div className="space-y-2 border-t pt-3">
<Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Page
</Button>
<p className="text-center text-[10px] text-muted-foreground">
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
save
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</form>
</>
);
}
// ── Toggle row ─────────────────────────────────────────────────
function ToggleRow({
id,
label,
description,
checked,
onChange,
disabled,
}: {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
{label}
</Label>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
/>
</div>
);
}
function TextareaField({
id,
label,
value,
onChange,
disabled,
rows = 3,
max,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
rows?: number;
max?: number;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{max && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{value.length}/{max}
</span>
)}
</div>
<Textarea
id={id}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
maxLength={max}
/>
</div>
);
}
// ── Layout select with color swatch ────────────────────────────
function LayoutSelect({
value,
onChange,
disabled,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((layout) => {
const colorClass =
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
return (
<SelectItem key={layout} value={layout}>
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
colorClass,
)}
/>
{layout}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
// ── Parent page combobox with search ────────────────────────────
function ParentCombobox({
value,
onChange,
pages,
currentPageId,
disabled,
}: {
value: number;
onChange: (id: number) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const options = pages.filter((p) => p.id !== currentPageId);
const selected =
value === -1
? { id: -1, caption: "Root" }
: pages.find((p) => p.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal"
>
{selected ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
{selected.id === -1 ? "—" : `#${selected.id}`}
</span>
{selected.caption}
</span>
) : (
<span className="text-muted-foreground">Select parent...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="root"
onSelect={() => {
onChange(-1);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === -1 ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
—
</span>
Root
</CommandItem>
{options.map((page) => (
<CommandItem
key={page.id}
value={`${page.id} ${page.caption}`}
onSelect={() => {
onChange(page.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === page.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,617 +0,0 @@
"use client";
import {
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { translateCatalogItems } from "@/actions/catalog-items";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServerAction } from "@/hooks/use-server-action";
import {
habboGamedataHotelLabel,
habboGamedataHotelShort,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { FurniIcon } from "./catalog-items-table";
interface BaseItemForTranslation {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogTranslateTabProps {
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
/** CMS setting `habbo_gamedata_hotel` — which official Habbo locale to suggest from */
gamedataHotel?: string;
}
type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
gamedataHotel = "it",
}: CatalogTranslateTabProps) {
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const [activeHotel, setActiveHotel] = useState(() =>
normalizeHabboGamedataHotel(gamedataHotel),
);
useEffect(() => {
setActiveHotel(normalizeHabboGamedataHotel(gamedataHotel));
}, [gamedataHotel]);
// Always refresh from CMS settings so the button label tracks hotel changes
// without requiring a full page remount after saving Settings.
useEffect(() => {
let cancelled = false;
async function refreshHotel() {
try {
const res = await fetch("/api/admin/catalog/suggest", {
cache: "no-store",
});
if (!res.ok) return;
const data = (await res.json()) as { hotel?: string };
if (!cancelled && typeof data.hotel === "string") {
setActiveHotel(normalizeHabboGamedataHotel(data.hotel));
}
} catch {
// keep prop/default
}
}
void refreshHotel();
const onFocus = () => void refreshHotel();
const onVisibility = () => {
if (document.visibilityState === "visible") void refreshHotel();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const hotelShort = habboGamedataHotelShort(activeHotel);
const hotelLabel = habboGamedataHotelLabel(activeHotel);
const furniDataIdSet = useMemo(
() => new Set(furniDataIdList),
[furniDataIdList],
);
const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
// Track which items have name or description changes
const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames],
);
const isDescChanged = useCallback(
(item: BaseItemForTranslation) =>
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap],
);
const changedItems = useMemo(
() =>
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged],
);
const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet],
);
const filteredItems = useMemo(() => {
switch (filterMode) {
case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified":
return baseItems.filter(
(item) => isNameChanged(item) || isDescChanged(item),
);
default:
return baseItems;
}
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
}, [filteredItems]);
// Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]);
function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value }));
}
function handleReset() {
setEditedNames(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
setEditedDescs(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
}
async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return;
setIsSuggesting(true);
try {
// Collect classnames from selected items (or all if none selected)
const classnames = targetItems
.map((item) => item.itemName)
.filter(Boolean);
if (classnames.length === 0) {
toast.info("No classnames found to look up.");
setIsSuggesting(false);
return;
}
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50;
const allSuggestions: Record<
string,
{ name: string; description: string }
> = {};
let suggestHotel = activeHotel;
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE);
const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
);
if (res.ok) {
const data = await res.json();
if (typeof data.hotel === "string") {
suggestHotel = normalizeHabboGamedataHotel(data.hotel);
setActiveHotel(suggestHotel);
}
Object.assign(allSuggestions, data.suggestions || {});
}
}
const suggestLabel = habboGamedataHotelLabel(suggestHotel);
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue;
if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name;
nameCount++;
}
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description;
descCount++;
}
}
setEditedNames(newNames);
setEditedDescs(newDescs);
const total = nameCount + descCount;
if (total > 0) {
const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(
`${parts.join(" + ")} suggested from ${suggestLabel}. Review and save.`,
);
} else {
toast.info(
`No new suggestions from ${suggestLabel} (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`Suggest error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
async function handleSaveAll() {
if (changedItems.length === 0) return;
run(
() =>
translateCatalogItems({
items: changedItems.map((item) => ({
id: item.id,
publicName: editedNames[item.id],
description: editedDescs[item.id],
})),
}),
{
successMessage: "Translations saved.",
errorMessage: "Failed to save translations.",
onSuccess: (data) => {
const d = data as Record<string, number | boolean>;
const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0)
parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0)
parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(
`${d.furniDataInserted} new item(s) added to FurnitureData.json`,
);
}
parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`);
router.refresh();
},
},
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>Translate Item Names</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Edit the public name and description of furniture items. Saving will
update:
</p>
<div className="flex gap-3 mt-2 flex-wrap">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
</span>
</div>
{missingItems.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{missingItems.length} item(s) missing from FurnitureData.json —
will be auto-inserted on save
</span>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end">
<Button
variant="outline"
size="sm"
onClick={handleSuggestFromHabboFurni}
disabled={isSuggesting || isPending || targetItems.length === 0}
title={`Suggest names + descriptions from ${hotelLabel}. Change hotel in Admin → Settings → Game assets.`}
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest {hotelShort}
{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={changedItems.length === 0 || isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All ({changedItems.length})
</Button>
</div>
)}
</CardHeader>
<CardContent>
{/* Filter bar */}
{baseItems.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
variant={filterMode === "all" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("all")}
>
All ({baseItems.length})
</Button>
<Button
variant={filterMode === "missing" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length})
</Button>
<Button
variant={filterMode === "modified" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0}
>
Modified ({changedItems.length})
</Button>
</div>
</div>
)}
{baseItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items to translate in this page.
</p>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match the current filter.
</p>
) : (
<TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead>Item Name</TableHead>
<TableHead className="min-w-[200px]">Public Name</TableHead>
<TableHead className="min-w-[200px]">Description</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => {
const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id];
return (
<TableRow
key={item.id}
className={
isChanged
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined
}
>
<TableCell className="px-2">
<Checkbox
checked={selected.has(item.id)}
onCheckedChange={() => toggleOne(item.id)}
aria-label={`Select ${item.itemName}`}
/>
</TableCell>
<TableCell className="px-1">
{(() => {
const revInfo = furniRevisionMap[item.id];
return revInfo ? (
<FurniIcon
classname={revInfo.classname}
revision={revInfo.revision}
/>
) : (
<FurniIcon
classname={item.itemName}
revision={0}
/>
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<Badge
variant={
item.type === "s" ? "default" : "secondary"
}
className="text-xs"
>
{item.type === "s"
? "Floor"
: item.type === "i"
? "Wall"
: item.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{item.itemName}
</TableCell>
<TableCell>
<Input
value={editedNames[item.id] || ""}
onChange={(e) =>
handleNameChange(item.id, e.target.value)
}
disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
{currentCatalogName &&
currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
catalog: {currentCatalogName}
</p>
)}
</TableCell>
<TableCell>
<Input
value={editedDescs[item.id] || ""}
onChange={(e) =>
handleDescChange(item.id, e.target.value)
}
disabled={!canEdit}
placeholder="No description"
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{isChanged ? (
<Badge
variant="secondary"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
>
Modified
</Badge>
) : (
<span className="text-xs text-muted-foreground">
—
</span>
)}
{isMissing ? (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-[var(--admin-warning)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>Missing from FurnitureData.json</p>
<p className="text-xs text-muted-foreground">
Will be auto-inserted on save
</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
In FurnitureData.json
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TooltipProvider>
)}
</CardContent>
</Card>
);
}
@@ -4,7 +4,7 @@ import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
import {
type CatalogItemData,
getPointsLabel,
} from "@/components/admin/catalog/detail/catalog-items-table/types";
} from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
CurrencyIcon,
currencyKindFromPointsType,
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ChartDataPoint {
label: string;
value: number;
}
export function DashboardChart({
title,
data,
description,
}: {
title: string;
data: ChartDataPoint[];
color?: string;
description?: string;
}) {
const max = Math.max(1, ...data.map((point) => point.value));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent>
<div
className="flex h-[200px] items-end gap-1"
aria-label={title}
role="img"
>
{data.map((point) => (
<div
key={point.label}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<span className="text-[10px] text-muted-foreground">
{point.value}
</span>
<div
className="w-full rounded-t bg-[var(--admin-accent)]"
style={{
height: `${Math.max(2, (point.value / max) * 150)}px`,
}}
title={`${point.label}: ${point.value}`}
/>
<span className="max-w-full truncate text-[9px] text-muted-foreground">
{point.label}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+553
View File
@@ -0,0 +1,553 @@
"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/common";
/**
* 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 overflow-x-auto">
<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 flex-wrap justify-end">
<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, 5) }, (_, 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,431 @@
"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-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: ""
}`}
>
<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-[var(--admin-warning-border)]" : ""
}`}
/>
{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>
);
}
+291
View File
@@ -0,0 +1,291 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { formatDate } from "@/lib/format-date";
type MediaFile = {
name: string;
url: string;
size: number;
uploaded: number;
};
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function extOf(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i + 1).toUpperCase() : "";
}
export function AdminMediaGrid() {
const t = useTranslations("pages.admin.media");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return files;
return files.filter((f) => f.name.toLowerCase().includes(q));
}, [files, query]);
async function handleUpload(fileList: FileList | File[]) {
const items = Array.from(fileList);
if (items.length === 0) return;
setUploading(true);
setError(null);
setProgress({ current: 0, total: items.length });
await Promise.all(
items.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
const res = await uploadMedia(fd);
if (!res.ok && res.error) setError(res.error);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
);
await load();
setUploading(false);
setProgress({ current: 0, total: 0 });
if (fileRef.current) fileRef.current.value = "";
}
function copyUrl(url: string) {
navigator.clipboard
.writeText(window.location.origin + url)
.then(() => {
setCopied(url);
setTimeout(() => setCopied(null), 1500);
})
.catch(() => {});
}
async function remove(name: string) {
const ok = await confirm({
title: t("delete"),
description: t("confirmDelete"),
confirmLabel: t("delete"),
variant: "danger",
});
if (!ok) return;
try {
await deleteMedia(name);
setFiles((prev) => prev.filter((f) => f.name !== name));
if (selected === name) setSelected(null);
} catch {
/* ignore */
}
}
return (
<div className="admin-card">
{confirmDialog}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? t("uploading") : t("upload")}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={(e) => handleUpload(e.target.files ?? [])}
multiple
className="hidden"
/>
</label>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="input input-bordered flex-1 min-w-[180px] text-sm"
/>
<span className="text-xs theme-text-muted dark:theme-text-muted whitespace-nowrap">
{filtered.length} / {files.length}
</span>
</div>
{uploading && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>{t("uploading")}</span>
<span>
{progress.current} / {progress.total}
</span>
</div>
<div className="h-2 w-full bg-[var(--admin-accent)]/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{error && (
<p className="text-xs text-[var(--admin-error)] mb-3">{error}</p>
)}
{/* Dropzone */}
<button
type="button"
aria-label={t("uploadSubtitle")}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files?.length) handleUpload(e.dataTransfer.files);
}}
className="rounded-xl border-2 border-dashed mb-4 text-center py-6 transition-colors duration-150"
style={{
borderColor: dragging
? "var(--admin-accent)"
: "color-mix(in srgb, var(--admin-text-muted) 25%, transparent)",
background: dragging
? "color-mix(in srgb, var(--admin-accent) 8%, transparent)"
: "transparent",
}}
>
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
{t("uploadSubtitle")}
</p>
</button>
{loading ? (
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("uploading")}
</p>
) : files.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("noFiles")}
</p>
) : filtered.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
{t("noFiles")}
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-3">
{filtered.map((f) => {
const isSelected = selected === f.name;
return (
// biome-ignore lint/a11y/useSemanticElements: grid tile uses role=button for selection
<div
key={f.name}
role="button"
tabIndex={0}
aria-label={f.name}
onClick={() => setSelected(f.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(f.name);
}
}}
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
style={{
borderColor: isSelected
? "var(--admin-accent)"
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
loading="lazy"
className="w-full h-[120px] object-cover block"
/>
<span className="absolute top-1.5 right-1.5 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-black/55 text-background uppercase">
{extOf(f.name)}
</span>
</div>
<div className="p-2">
<p className="text-xs m-0 mb-0.5 overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{f.name}
</p>
<p className="text-[11px] m-0 text-[var(--admin-text-muted)]">
{formatSize(f.size)} ·{" "}
{formatDate(new Date(f.uploaded), "date")}
</p>
<div className="flex gap-1.5 mt-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
copyUrl(f.url);
}}
className="btn btn-outline text-[11px] py-1 flex-1"
>
{copied === f.url ? t("urlCopied") : t("copyUrl")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(f.name);
}}
className="btn text-[11px] py-1 px-2 text-[var(--admin-error)]"
>
{t("delete")}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadMediaAndReturn } from "@/actions/admin-media";
type MediaFile = { name: string; url: string };
function validImageUrl(url: string): string {
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
export function MediaPicker({
onSelect,
current,
}: {
onSelect: (url: string) => void;
current?: string;
}) {
const [open, setOpen] = useState(false);
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
if (open) load();
}, [open, load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
const urls: string[] = [];
for (const file of Array.from(files)) {
const fd = new FormData();
fd.set("file", file);
const url = await uploadMediaAndReturn(fd);
if (url) urls.push(url);
}
if (urls.length > 0) {
await load();
onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
setOpen(false);
}
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
return (
<>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-outline"
style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}
>
Browse
</button>
{current && (
<span
style={{
fontSize: "0.78rem",
color: "var(--admin-text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 200,
}}
>
{current.split("/").pop()}
</span>
)}
</div>
{open && (
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
<div
role="button"
tabIndex={0}
aria-label="Close media picker"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(false);
}
}}
>
<div
role="dialog"
aria-modal="true"
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
multiple
/>
</label>
<button
type="button"
onClick={() => setOpen(false)}
className="btn text-sm"
>
Close
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
Loading…
</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => {
onSelect(f.url);
setOpen(false);
}}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
style={{
borderColor:
f.url === current
? "var(--admin-accent)"
: "transparent",
}}
onMouseEnter={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
}}
onMouseLeave={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface PeakHoursData {
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
grid: number[][];
max: number;
}
const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function getColor(value: number, max: number) {
if (max === 0 || value === 0) return "bg-muted";
const ratio = value / max;
if (ratio < 0.25) return "bg-[var(--admin-success)]/20";
if (ratio < 0.5) return "bg-[var(--admin-success)]/55";
if (ratio < 0.75)
return "bg-[var(--admin-success)] dark:bg-[var(--admin-success)]";
return "bg-[var(--admin-success-strong,var(--admin-success))]";
}
export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
const t = useTranslations("pages.admin.analytics.peakHours");
const days = DAY_KEYS.map((key) => t(`days.${key}`));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{t("title")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("description")}</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[500px]">
{/* Hour labels */}
<div className="flex gap-px ml-10 mb-1">
{HOURS.map((h) => (
<div
key={h}
className="flex-1 text-center text-[10px] text-muted-foreground"
>
{h % 3 === 0 ? `${h}` : ""}
</div>
))}
</div>
{/* Rows */}
{days.map((day, dayIdx) => (
<div
key={DAY_KEYS[dayIdx]}
className="flex items-center gap-px mb-px"
>
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
{day}
</span>
<TooltipProvider delayDuration={0}>
{HOURS.map((hour) => {
const count = data.grid[dayIdx]?.[hour] ?? 0;
return (
<Tooltip key={hour}>
<TooltipTrigger asChild>
<div
className={`flex-1 aspect-square rounded-sm ${getColor(count, data.max)} transition-colors min-w-[14px]`}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{t("tooltip", { day, hour, count })}
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
))}
{/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">
{t("less")}
</span>
<div className="h-3 w-3 rounded-sm bg-muted" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" />
<span className="text-[10px] text-muted-foreground">
{t("more")}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface RevenueDataPoint {
label: string;
credits: number;
points: number;
}
export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
const t = useTranslations("pages.admin.analytics.economy");
const max = Math.max(
1,
...data.flatMap((point) => [point.credits, point.points]),
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{t("catalogSpendingTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.map((point) => (
<div
key={point.label}
className="grid grid-cols-[3rem_1fr] items-center gap-2 text-xs"
>
<span>{point.label}</span>
<div className="space-y-1">
<div
className="h-2 rounded bg-[var(--admin-warning)]"
style={{
width: `${Math.max(2, (point.credits / max) * 100)}%`,
}}
title={t("creditsTooltip", { count: point.credits })}
/>
<div
className="h-2 rounded bg-[var(--admin-accent)]"
style={{
width: `${Math.max(2, (point.points / max) * 100)}%`,
}}
title={t("pointsTooltip", { count: point.points })}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
// is the documented sentinel for the free self-hosted/CDN build and silences
// the "register for an API key" notification.
const TINYMCE_SRC = "https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
remove: (selector: string) => void;
};
declare global {
interface Window {
tinymce?: TinyMCEGlobal;
}
}
let loaderPromise: Promise<TinyMCEGlobal | null> | null = null;
/** Inject the TinyMCE script once; resolves null if it fails to load. */
function loadTinyMce(): Promise<TinyMCEGlobal | null> {
if (typeof window === "undefined") return Promise.resolve(null);
if (window.tinymce) return Promise.resolve(window.tinymce);
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(
"script[data-tinymce]",
);
const finish = () => resolve(window.tinymce ?? null);
if (existing) {
existing.addEventListener("load", finish, { once: true });
existing.addEventListener("error", () => resolve(null), { once: true });
if (window.tinymce) resolve(window.tinymce);
return;
}
const script = document.createElement("script");
script.src = TINYMCE_SRC;
script.async = true;
script.referrerPolicy = "origin";
script.dataset.tinymce = "1";
script.addEventListener("load", finish, { once: true });
script.addEventListener("error", () => resolve(null), { once: true });
document.head.appendChild(script);
});
return loaderPromise;
}
/**
* WYSIWYG editor over a plain <textarea>. Keeps the original `name` so the
* surrounding server-action form still receives the HTML on submit. If TinyMCE
* fails to load (offline, CDN blocked) it silently degrades to the textarea.
*/
export function RichText({
name,
defaultValue = "",
placeholder,
rows = 10,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
rows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const selector = `#${editorId}`;
loadTinyMce().then((tinymce) => {
if (cancelled || !tinymce || !textareaRef.current) return;
tinymce
.init({
selector,
menubar: false,
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
branding: false,
promotion: false,
license_key: "gpl",
skin: "oxide",
content_style: "body{font-family:inherit;font-size:14px}",
// Mirror typed HTML back into the underlying textarea so the form
// submit (server action) always sees the current value.
setup: (editor: {
on: (ev: string, cb: () => void) => void;
getContent: () => string;
}) => {
const sync = () => {
if (textareaRef.current)
textareaRef.current.value = editor.getContent();
};
editor.on("change keyup undo redo SetContent", sync);
},
})
.then(() => {
if (!cancelled) setReady(true);
})
.catch(() => {
/* leave the plain textarea in place */
});
});
return () => {
cancelled = true;
// Detach the editor instance on unmount; ignore if TinyMCE never loaded.
try {
window.tinymce?.remove(selector);
} catch {
/* noop */
}
};
}, [editorId, rows]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
// Hide the raw textarea only once TinyMCE has mounted (it replaces it with
// its own iframe); until then the plain field is the visible fallback.
style={ready ? { display: "none" } : undefined}
/>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { Minus, TrendingDown, TrendingUp } from "lucide-react";
import Link from "@/components/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface StatsCardProps {
title: string;
value: string | number;
description?: string;
icon?: React.ReactNode;
href?: string;
/** Percentage change vs previous period. Positive = up, negative = down, undefined = no trend */
trend?: number;
}
export function StatsCard({
title,
value,
description,
icon,
href,
trend,
}: StatsCardProps) {
const content = (
<Card
className={
href
? "transition-all duration-200 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer"
: ""
}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{value}</div>
{trend !== undefined && <TrendBadge value={trend} />}
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</CardContent>
</Card>
);
if (href) {
return <Link href={href}>{content}</Link>;
}
return content;
}
function TrendBadge({ value }: { value: number }) {
if (value === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
0%
</span>
);
}
const isUp = value > 0;
return (
<span
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
isUp ? "text-[var(--admin-success)]" : "text-[var(--admin-error)]"
}`}
>
{isUp ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{isUp ? "+" : ""}
{value}%
</span>
);
}
@@ -0,0 +1,190 @@
"use client";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
function SummaryRow({
label,
value,
tone,
}: {
label: string;
value: string | number;
tone?: "ok" | "warn";
}) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<span
className={cn(
"font-mono font-semibold",
tone === "warn"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
{value}
</span>
</div>
);
}
export function BatchProgress({
progress,
done,
succeeded,
failed,
cancelling,
verification,
onCancel,
onDismiss,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
succeeded: number;
failed: number;
cancelling?: boolean;
verification?: VerificationSummary | null;
onCancel?: () => void;
onDismiss?: () => void;
}) {
const entries = [...progress.entries()];
const total = entries.length;
const completed = entries.filter(
([, s]) => s.status === "done" || s.status === "failed",
).length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{done ? "Import finished" : "Importing…"}
</span>
{done ? (
<span
className={cn(
"text-xs font-bold",
failed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
{completed}/{total} · {succeeded} ok
{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<span className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
</span>
)}
</div>
{!done && (
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([classname, s]) => (
<div key={classname} className="flex items-center gap-2 text-xs">
{s.status === "done" ? (
<Check
size={12}
className="shrink-0 text-[var(--admin-success)]"
/>
) : s.status === "failed" ? (
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
) : (
<Loader2
size={12}
className="shrink-0 animate-spin text-[var(--admin-accent)]"
/>
)}
<span className="min-w-0 flex-1 truncate font-mono">
{classname}
</span>
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
{s.status.replace(/_/g, " ")}
</span>
</div>
))}
</div>
{done && verification && (
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
Post-import verification
</div>
<SummaryRow
label="Catalog offer_id fixed"
value={verification.offerIdsFixed}
/>
<SummaryRow
label="Furnidata ids fixed"
value={verification.furniDataFixedIds}
/>
<SummaryRow
label="Furnidata entries missing"
value={verification.furniDataMissing}
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Furnidata id conflicts"
value={verification.furniDataConflicts}
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Nitros synced"
value={verification.nitrosSynced.length}
/>
<SummaryRow
label="Icons synced"
value={verification.iconsSynced.length}
/>
<SummaryRow
label="Folders chowned"
value={verification.ownershipFixed.length}
/>
</div>
)}
<div className="mt-2 flex justify-end gap-2">
{done ? (
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Ban size={14} />
)}
Cancel
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
export function CatalogRail({
tree,
selectedId,
onSelect,
}: {
tree: TreeNode[];
selectedId: number | null;
onSelect: (id: number | null) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
const hidden = new Set<number>();
for (const node of tree) {
if (hidden.has(node.parentId)) {
hidden.add(node.id);
continue;
}
if (collapsed.has(node.parentId)) {
hidden.add(node.id);
}
}
return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]);
const toggle = (id: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
<FolderTree size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Catalog
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{visible.length === 0 ? (
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
No catalog pages found.
</p>
) : (
visible.map((node) => {
const isRoot = node.parentId <= 0;
const hasChildren = node.childCount > 0;
const isCollapsed = collapsed.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => {
if (hasChildren) toggle(node.id);
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
>
{hasChildren ? (
isCollapsed ? (
<ChevronRight size={12} className="shrink-0" />
) : (
<ChevronDown size={12} className="shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">
{isRoot ? (
<span className="font-semibold">{node.caption}</span>
) : (
node.caption
)}
</span>
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
{node.itemCount}
</span>
</button>
);
})
)}
</div>
</aside>
);
}
@@ -0,0 +1,22 @@
"use client";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function CheckboxDot({ checked }: { checked: boolean }) {
return (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded border transition-colors",
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent",
)}
>
{checked ? (
<Check size={10} className="text-[var(--admin-accent-foreground)]" />
) : null}
</span>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
import type { SyntheticEvent } from "react";
import type { FurniItem } from "./studio-types";
export function getFurniImageUrl(item: FurniItem) {
if (item.iconUrl) return item.iconUrl;
const base = item.classname.includes("*")
? item.classname.substring(0, item.classname.indexOf("*"))
: item.classname;
return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`;
}
export function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
export function handleImgError(
e: SyntheticEvent<HTMLImageElement>,
item: FurniItem,
) {
const img = e.target as HTMLImageElement;
const iconName = item.classname.replace(/\*/g, "_");
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = getFurniIconUrl(item.classname, item.revision);
} else if (img.dataset.fallback === "1") {
img.dataset.fallback = "2";
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
} else {
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
export const TYPE_LABELS: Record<string, string> = {
flooritem: "Floor",
wallitem: "Wall",
};
@@ -0,0 +1,53 @@
export interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
iconUrl?: string;
}
export interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
export interface FurniStats {
totalInDb: number;
inCatalog: number;
notInCatalog: number;
missingNitro: number;
}
export interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
/** Post-import verification results reported by the import API. */
export interface VerificationSummary {
offerIdsFixed: number;
furniDataFixedIds: number;
furniDataMissing: number;
furniDataConflicts: number;
nitrosSynced: string[];
iconsSynced: string[];
ownershipFixed: string[];
}
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { renameCustomTheme } from "@/actions/admin-theme";
export function RenameTheme({ id, name }: { id: string; name: string }) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
if (!editing) {
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] no-underline"
>
Rename
</button>
);
}
return (
<form action={renameCustomTheme} className="flex items-center gap-1">
<input type="hidden" name="id" value={id} />
<input
name="name"
value={val}
onChange={(e) => setVal(e.target.value)}
className="w-28 px-1.5 py-0.5 rounded-md border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-xs"
style={{ borderColor: "var(--admin-border)" }}
/>
<button
type="submit"
className="text-xs font-semibold text-[var(--admin-accent)]"
>
OK
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-xs text-[var(--admin-text-muted)]"
>
✕
</button>
</form>
);
}
@@ -0,0 +1,35 @@
import Link from "@/components/link";
/** Shared clarity banner for dual ticket products (CMS desk vs help-center). */
export function TicketQueueBanner({
hint,
otherHref,
otherLabel,
countsLine,
}: {
hint: string;
otherHref: string;
otherLabel: string;
/** Optional preformatted open-count summary (locale-aware from caller). */
countsLine?: string;
}) {
return (
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
<p className="m-0">{hint}</p>
{countsLine ? (
<p className="m-0 mt-1 text-muted-foreground">{countsLine}</p>
) : null}
<p className="m-0 mt-1">
<Link
href={otherHref}
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{otherLabel}
</Link>
</p>
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import type { TicketInboxRow } from "@/lib/admin/ticket-inbox";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
export function UnifiedTicketsTable({
data,
}: {
data: PaginatedResult<TicketInboxRow>;
}) {
const t = useTranslations("pages.admin.ticketInbox");
const columns: DataTableColumn<TicketInboxRow>[] = [
{
key: "title",
label: t("colTicket"),
sortable: false,
render: (_value, row) => (
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge
variant={row.kind === "cms" ? "default" : "outline"}
className="shrink-0 text-[0.65rem]"
>
{row.kind === "cms" ? t("typeCms") : t("typeHelp")}
</Badge>
<span className="text-xs text-muted-foreground font-mono">
#{row.id}
</span>
<Link
href={row.href}
className="font-medium truncate hover:underline"
>
{row.title}
</Link>
</div>
<div className="text-xs text-muted-foreground">
{t("meta", { user: row.user })}
</div>
</div>
),
},
{
key: "status",
label: t("colStatus"),
sortable: false,
filterKey: "filter_status",
filterOptions: [
{ label: t("statusOpen"), value: "open" },
{ label: t("statusAll"), value: "all" },
],
render: (_value, row) => (
<Badge
variant={row.open ? "default" : "secondary"}
className="text-[0.7rem] capitalize"
>
{row.kind === "cms"
? row.status.replace("_", " ")
: row.open
? t("statusOpen")
: t("statusClosed")}
</Badge>
),
},
{
key: "kind",
label: t("colType"),
sortable: false,
filterKey: "filter_type",
filterOptions: [
{ label: t("typeAll"), value: "all" },
{ label: t("typeCms"), value: "cms" },
{ label: t("typeHelp"), value: "help" },
],
render: (value) => {
const kind = String(value);
return (
<span className="text-xs text-muted-foreground">
{kind === "cms" ? t("typeCms") : t("typeHelp")}
</span>
);
},
},
{ key: "date", label: t("colUpdated"), sortable: false },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{
label: t("presetOpen"),
params: { filter_status: "open", filter_type: "all" },
},
{
label: t("typeCms"),
params: { filter_type: "cms", filter_status: "open" },
},
{
label: t("typeHelp"),
params: { filter_type: "help", filter_status: "open" },
},
{
label: t("statusAll"),
params: { filter_status: "all", filter_type: "all" },
},
]}
/>
);
}
@@ -0,0 +1,62 @@
"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>
);
}