Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
-3
@@ -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} · 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" : "";
|
||||
}
|
||||
@@ -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 "
|
||||
{searchQuery}"
|
||||
</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 · 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 "{inputValue.trim()}"
|
||||
</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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user