style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+23 -20
View File
@@ -7,27 +7,30 @@ export type { BreadcrumbItem } from "@/types/common";
import type { BreadcrumbItem } from "@/types/common";
interface AdminBreadcrumbProps {
items: BreadcrumbItem[];
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>
);
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>
);
}
+61 -44
View File
@@ -1,54 +1,71 @@
"use client";
import { useState, useEffect, type ReactNode } from "react";
import { Menu, X } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
export function AdminMobileWrapper({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
const [open, setOpen] = useState(false);
export function AdminMobileWrapper({
sidebar,
children,
}: {
sidebar: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
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 (
<div className="min-h-screen bg-[var(--admin-canvas)] flex flex-col lg:flex-row">
{/* Mobile header */}
<div
className="lg:hidden sticky top-0 z-50 flex items-center gap-3 px-4 py-3 border-b"
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 transition-colors hover:bg-[var(--admin-accent)]/15"
style={{ color: "var(--admin-text-muted)" }}
>
{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>
return (
<div className="min-h-screen bg-[var(--admin-canvas)] flex flex-col lg:flex-row">
{/* Mobile header */}
<div
className="lg:hidden sticky top-0 z-50 flex items-center gap-3 px-4 py-3 border-b"
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 transition-colors hover:bg-[var(--admin-accent)]/15"
style={{ color: "var(--admin-text-muted)" }}
>
{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 && <div className="fixed inset-0 z-40 lg:hidden bg-black/50" onClick={() => setOpen(false)} />}
{/* Backdrop */}
{open && (
<div
className="fixed inset-0 z-40 lg:hidden bg-black/50"
onClick={() => setOpen(false)}
/>
)}
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 w-[260px] transform transition-transform duration-300 ease-in-out lg:!relative lg:!translate-x-0 lg:!min-h-screen ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
{sidebar}
</div>
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 w-[260px] transform transition-transform duration-300 ease-in-out lg:!relative lg:!translate-x-0 lg:!min-h-screen ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
{sidebar}
</div>
{/* Content */}
<div className="flex flex-col flex-1 min-w-0">{children}</div>
</div>
);
{/* Content */}
<div className="flex flex-col flex-1 min-w-0">{children}</div>
</div>
);
}
+36 -34
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
/**
* Sidebar link that highlights when its route is active. "/admin" matches
@@ -10,40 +10,42 @@ import { usePathname } from "next/navigation";
* active on /admin/radio/history) so the current section stays lit.
*/
export function AdminNavLink({
href,
icon,
children,
href,
icon,
children,
}: {
href: string;
icon?: ReactNode;
children: ReactNode;
href: string;
icon?: ReactNode;
children: ReactNode;
}) {
const pathname = usePathname() ?? "";
const active =
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
const pathname = usePathname() ?? "";
const active =
href === "/admin"
? pathname === "/admin"
: pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`group flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm no-underline transition-all duration-150 ${
active
? "bg-[var(--admin-accent)]/20 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>
);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`group flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm no-underline transition-all duration-150 ${
active
? "bg-[var(--admin-accent)]/20 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>
);
}
+85 -61
View File
@@ -5,68 +5,92 @@ import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text";
export function ArticleForm({
action,
defaultValues,
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<void>;
defaultValues?: { title?: string; image?: string; shortStory?: string; fullStory?: string };
action: (formData: FormData) => Promise<void>;
defaultValues?: {
title?: string;
image?: string;
shortStory?: string;
fullStory?: string;
};
}) {
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
return (
<form action={action} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="title" defaultValue={defaultValues?.title ?? ""} placeholder="Title" required />
<div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }}>
<input
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder="Image URL"
style={{ 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)}
style={{ marginTop: "0.4rem", maxHeight: 120, borderRadius: 6, objectFit: "cover" }}
/>
) : null}
{imageError ? (
<p className="muted" style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder="Short story (summary)"
rows={2}
/>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder="Full story"
rows={10}
/>
<button type="submit" className="btn btn-primary">
{defaultValues ? "Save" : "Create"}
</button>
</form>
);
return (
<form
action={action}
className="card"
style={{ display: "grid", gap: "0.6rem" }}
>
<input
name="title"
defaultValue={defaultValues?.title ?? ""}
placeholder="Title"
required
/>
<div>
<div
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }}
>
<input
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder="Image URL"
style={{ 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)}
style={{
marginTop: "0.4rem",
maxHeight: 120,
borderRadius: 6,
objectFit: "cover",
}}
/>
) : null}
{imageError ? (
<p
className="muted"
style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}
>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder="Short story (summary)"
rows={2}
/>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder="Full story"
rows={10}
/>
<button type="submit" className="btn btn-primary">
{defaultValues ? "Save" : "Create"}
</button>
</form>
);
}
@@ -5,54 +5,58 @@ import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const { state, dispatch } = useCatalogManager();
const { selectedPageId, ancestors } = state;
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { 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]);
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, {
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]);
if (!selectedPageId || ancestors.length === 0) return null;
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>
);
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>
);
}
@@ -5,7 +5,13 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
@@ -16,158 +22,176 @@ import { StatsBar } from "./stats-bar";
import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
export function CatalogManagerDialog() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<DialogContent
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
onInteractOutside={(e) => e.preventDefault()}
>
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<DialogContent
className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]"
onInteractOutside={(e) => e.preventDefault()}
>
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
</DialogContent>
</Dialog>
);
}
function CatalogManagerInner({
open,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
open,
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { state, dispatch } = useCatalogManager();
const { state, dispatch } = useCatalogManager();
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: RootTab[] = (data.pages || []).map((p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
// Load root pages when dialog opens
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
id: p.id as number,
caption: p.caption as string,
iconImage: p.iconImage as number,
enabled: String(p.enabled),
orderNum: (p.orderNum as number) || 0,
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
// Auto-select first tab if none or missing
if (pages.length > 0 && (!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))) {
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]);
// Auto-select first tab if none or missing
if (
pages.length > 0 &&
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))
) {
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]);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (open) loadRootPages();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle dialog close: preserve tree state, clear selection
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
// Handle dialog close: preserve tree state, clear selection
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 });
dispatch({ type: "CLEAR_TREE" });
};
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?"))
return;
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
};
const handleCreateRoot = async () => {
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result = 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 handleCreateRoot = async () => {
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result = 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 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) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) return;
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditTab = (id: number) => {
// First ensure this tab is active, then select it in the editor
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
}
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 handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
// Update root tabs if root page
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 });
}
};
// Update root tabs if root page
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 });
}
};
return (
<TooltipProvider delayDuration={300}>
{/* Header */}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
</DialogHeader>
return (
<TooltipProvider delayDuration={300}>
{/* Header */}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
</DialogHeader>
{/* Breadcrumb */}
<BreadcrumbBar />
{/* Breadcrumb */}
<BreadcrumbBar />
{/* Root tabs */}
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{/* Root tabs */}
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{/* Main content: tree + editor */}
<div className="flex flex-1 min-h-0">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor pageId={state.selectedPageId} onSaved={handleEditorSaved} />
</div>
{/* Main content: tree + editor */}
<div className="flex flex-1 min-h-0">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
);
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
);
}
@@ -4,85 +4,89 @@ import { ImageOff, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
interface ImagePreviewProps {
value: string;
basePath?: string;
height?: number;
className?: string;
value: string;
basePath?: string;
height?: number;
className?: string;
}
export function ImagePreview({
value,
basePath = "/swf/c_images/catalogue",
height = 60,
className,
value,
basePath = "/swf/c_images/catalogue",
height = 60,
className,
}: ImagePreviewProps) {
const [src, setSrc] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "loaded" | "error">("idle");
const [fallback, setFallback] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [src, setSrc] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "loaded" | "error">(
"idle",
);
const [fallback, setFallback] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!value.trim()) {
setSrc("");
setStatus("idle");
setFallback(false);
return;
}
useEffect(() => {
if (!value.trim()) {
setSrc("");
setStatus("idle");
setFallback(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFallback(false);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFallback(false);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value, basePath]);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value, basePath]);
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
} else {
setStatus("error");
}
}
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
} else {
setStatus("error");
}
}
if (status === "idle" || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ""}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
);
}
if (status === "idle" || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ""}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
);
}
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ""}`}
style={{ height }}
>
{status === "loading" && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onLoad={() => setStatus("loaded")}
onError={handleError}
/>
{status === "error" && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ""}`}
style={{ height }}
>
{status === "loading" && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onLoad={() => setStatus("loaded")}
onError={handleError}
/>
{status === "error" && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+248 -184
View File
@@ -1,31 +1,44 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
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 {
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
} from "@/actions/catalog";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type { RootTab } from "@/types/catalog";
@@ -33,199 +46,250 @@ export type { RootTab } from "@/types/catalog";
import type { RootTab } from "@/types/catalog";
interface RootTabsProps {
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void;
onEditTab: (id: number) => void;
onCreateRoot: () => void;
loading?: boolean;
tabs: RootTab[];
activeTabId: number | null;
onTabClick: (id: number) => void;
onEditTab: (id: number) => void;
onCreateRoot: () => void;
loading?: boolean;
}
export function RootTabs({ tabs, activeTabId, onTabClick, onEditTab, onCreateRoot, loading }: RootTabsProps) {
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
export function RootTabs({
tabs,
activeTabId,
onTabClick,
onEditTab,
onCreateRoot,
loading,
}: RootTabsProps) {
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 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 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;
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) => reorderTreePage({ 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],
);
// Batch reorder
try {
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorderTreePage({ 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],
);
const handleToggle = async (tab: RootTab) => {
try {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
toast.success(`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`);
} catch {
toast.error("Failed to toggle");
}
};
const handleToggle = async (tab: RootTab) => {
try {
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 (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return;
try {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
toast.success(`"${tab.caption}" deleted`);
} catch {
toast.error("Failed to delete");
}
};
const handleDelete = async (tab: RootTab) => {
if (
!confirm(
`Delete root page "${tab.caption}"? Children will be reparented.`,
)
)
return;
try {
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;
const draggedTab = activeDragId
? tabs.find((t) => t.id === activeDragId)
: null;
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
{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>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 ml-1" onClick={onCreateRoot}>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
</div>
);
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
{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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
</div>
);
}
// ── Sortable root tab ─────────────────────────────────────────
function SortableRootTab({
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
tab,
isActive,
onClick,
onEdit,
onToggle,
onDelete,
}: {
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
tab: RootTab;
isActive: boolean;
onClick: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
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,
};
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>
);
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,
isActive,
isOverlay,
onClick,
onDoubleClick,
}: {
tab: RootTab;
isActive: boolean;
isOverlay?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
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>
);
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
@@ -5,50 +5,61 @@ import { useMemo } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function StatsBar() {
const { state } = useCatalogManager();
const { state } = useCatalogManager();
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values());
const enabled = nodes.filter((n) => n.enabled === "1").length;
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0);
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
};
}, [state.nodes, state.rootPages]);
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values());
const enabled = nodes.filter((n) => n.enabled === "1").length;
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0);
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
};
}, [state.nodes, state.rootPages]);
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" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">{state.multiSelectedIds.size} 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>
);
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" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">
{state.multiSelectedIds.size} 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>
);
}
+302 -258
View File
@@ -3,290 +3,334 @@
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,
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,
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;
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;
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 {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.node.id,
disabled: !props.sortMode,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
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>
);
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";
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>
)}
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" />
)}
{/* 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} />
{/* 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>
{/* 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>
);
{/* 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;
// 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
);
},
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
);
},
);
@@ -1,264 +1,300 @@
"use client";
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from "react";
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;
// 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;
// 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;
// Editor
editorDirty: boolean;
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>;
// 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: [],
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_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" };
| { 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_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,
};
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 "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_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_NODES": {
const next = new Map(state.nodes);
for (const n of action.nodes) next.set(n.id, n);
return { ...state, nodes: next };
}
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 "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 "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 "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 "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: [],
};
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;
}
default:
return state;
}
}
// ── Context ───────────────────────────────────────────────────
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(null);
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
null,
);
export function CatalogManagerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>{children}</CatalogManagerContext.Provider>
);
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>
{children}
</CatalogManagerContext.Provider>
);
}
export function useCatalogManager() {
const ctx = useContext(CatalogManagerContext);
if (!ctx) throw new Error("useCatalogManager must be used within CatalogManagerProvider");
return ctx;
const ctx = useContext(CatalogManagerContext);
if (!ctx)
throw new Error(
"useCatalogManager must be used within CatalogManagerProvider",
);
return ctx;
}
File diff suppressed because it is too large. Load diff
+232 -187
View File
@@ -6,208 +6,253 @@ import { toast } from "sonner";
import { createCatalogItem } from "@/actions/catalog-items";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface ParsedRow {
lineNumber: number;
raw: string;
ok: boolean;
error?: string;
baseId?: number;
credits?: number;
points?: number;
pointsType?: number;
lineNumber: number;
raw: string;
ok: boolean;
error?: string;
baseId?: number;
credits?: number;
points?: number;
pointsType?: number;
}
interface BulkImportItemsProps {
pageId: number;
onImported?: () => void;
pageId: number;
onImported?: () => void;
}
export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [importing, setImporting] = useState(false);
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [importing, setImporting] = useState(false);
const parsed = useMemo<ParsedRow[]>(() => {
const lines = input.split("\n");
const out: ParsedRow[] = [];
lines.forEach((raw, idx) => {
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const parts = trimmed.split(/[,\s;]+/).filter(Boolean);
const baseId = parseInt(parts[0], 10);
if (Number.isNaN(baseId) || baseId <= 0) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: "Invalid base ID" });
return;
}
const credits = parts[1] !== undefined ? parseInt(parts[1], 10) : 0;
const points = parts[2] !== undefined ? parseInt(parts[2], 10) : 0;
const pointsType = parts[3] !== undefined ? parseInt(parts[3], 10) : 0;
if (
(parts[1] !== undefined && Number.isNaN(credits)) ||
(parts[2] !== undefined && Number.isNaN(points))
) {
out.push({ lineNumber: idx + 1, raw: trimmed, ok: false, error: "Invalid number" });
return;
}
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: true,
baseId,
credits,
points,
pointsType,
});
});
return out;
}, [input]);
const parsed = useMemo<ParsedRow[]>(() => {
const lines = input.split("\n");
const out: ParsedRow[] = [];
lines.forEach((raw, idx) => {
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const parts = trimmed.split(/[,\s;]+/).filter(Boolean);
const baseId = parseInt(parts[0], 10);
if (Number.isNaN(baseId) || baseId <= 0) {
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: false,
error: "Invalid base ID",
});
return;
}
const credits = parts[1] !== undefined ? parseInt(parts[1], 10) : 0;
const points = parts[2] !== undefined ? parseInt(parts[2], 10) : 0;
const pointsType = parts[3] !== undefined ? parseInt(parts[3], 10) : 0;
if (
(parts[1] !== undefined && Number.isNaN(credits)) ||
(parts[2] !== undefined && Number.isNaN(points))
) {
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: false,
error: "Invalid number",
});
return;
}
out.push({
lineNumber: idx + 1,
raw: trimmed,
ok: true,
baseId,
credits,
points,
pointsType,
});
});
return out;
}, [input]);
const validCount = parsed.filter((r) => r.ok).length;
const invalidCount = parsed.length - validCount;
const validCount = parsed.filter((r) => r.ok).length;
const invalidCount = parsed.length - validCount;
async function handleImport() {
const valid = parsed.filter((r) => r.ok);
if (valid.length === 0) {
toast.error("No valid rows to import");
return;
}
setImporting(true);
let success = 0;
let failed = 0;
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: "",
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
if (res.ok) success++;
else failed++;
} catch {
failed++;
}
}
setImporting(false);
if (success > 0) {
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`);
setInput("");
setOpen(false);
onImported?.();
} else {
toast.error(`All ${failed} imports failed.`);
}
}
async function handleImport() {
const valid = parsed.filter((r) => r.ok);
if (valid.length === 0) {
toast.error("No valid rows to import");
return;
}
setImporting(true);
let success = 0;
let failed = 0;
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: "",
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
if (res.ok) success++;
else failed++;
} catch {
failed++;
}
}
setImporting(false);
if (success > 0) {
toast.success(
`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`,
);
setInput("");
setOpen(false);
onImported?.();
} else {
toast.error(`All ${failed} imports failed.`);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
Bulk Import
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bulk Import Items</DialogTitle>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
Bulk Import
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bulk Import Items</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Item list</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`One per line: base_id[, credits[, points[, points_type]]]\n# Lines starting with # are ignored\n12345, 50\n12346, 0, 10, 5\n12347`}
rows={10}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Format: <code className="rounded bg-muted px-1">base_id, credits, points, points_type</code>.
Only <code>base_id</code> is required; other fields default to 0. Separators: comma, space, or
semicolon.
</p>
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label>Item list</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={`One per line: base_id[, credits[, points[, points_type]]]\n# Lines starting with # are ignored\n12345, 50\n12346, 0, 10, 5\n12347`}
rows={10}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Format:{" "}
<code className="rounded bg-muted px-1">
base_id, credits, points, points_type
</code>
. Only <code>base_id</code> is required; other fields default to
0. Separators: comma, space, or semicolon.
</p>
</div>
{parsed.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3 text-xs">
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{validCount} valid
</Badge>
{invalidCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{invalidCount} invalid
</Badge>
)}
</div>
<div className="max-h-[200px] overflow-y-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr>
<th className="px-2 py-1 text-left">Line</th>
<th className="px-2 py-1 text-left">Base ID</th>
<th className="px-2 py-1 text-left">Credits</th>
<th className="px-2 py-1 text-left">Points</th>
<th className="px-2 py-1 text-left">Type</th>
<th className="px-2 py-1 text-left">Status</th>
</tr>
</thead>
<tbody>
{parsed.map((row) => (
<tr key={row.lineNumber} className={row.ok ? "" : "bg-destructive/10"}>
<td className="px-2 py-0.5 font-mono text-muted-foreground">{row.lineNumber}</td>
<td className="px-2 py-0.5 font-mono">{row.baseId ?? "—"}</td>
<td className="px-2 py-0.5 font-mono">{row.credits ?? "—"}</td>
<td className="px-2 py-0.5 font-mono">{row.points ?? "—"}</td>
<td className="px-2 py-0.5 font-mono">{row.pointsType ?? "—"}</td>
<td className="px-2 py-0.5">
{row.ok ? (
<span className="text-[var(--admin-success)]">OK</span>
) : (
<span className="text-destructive">{row.error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{parsed.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3 text-xs">
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{validCount} valid
</Badge>
{invalidCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{invalidCount} invalid
</Badge>
)}
</div>
<div className="max-h-[200px] overflow-y-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr>
<th className="px-2 py-1 text-left">Line</th>
<th className="px-2 py-1 text-left">Base ID</th>
<th className="px-2 py-1 text-left">Credits</th>
<th className="px-2 py-1 text-left">Points</th>
<th className="px-2 py-1 text-left">Type</th>
<th className="px-2 py-1 text-left">Status</th>
</tr>
</thead>
<tbody>
{parsed.map((row) => (
<tr
key={row.lineNumber}
className={row.ok ? "" : "bg-destructive/10"}
>
<td className="px-2 py-0.5 font-mono text-muted-foreground">
{row.lineNumber}
</td>
<td className="px-2 py-0.5 font-mono">
{row.baseId ?? "—"}
</td>
<td className="px-2 py-0.5 font-mono">
{row.credits ?? "—"}
</td>
<td className="px-2 py-0.5 font-mono">
{row.points ?? "—"}
</td>
<td className="px-2 py-0.5 font-mono">
{row.pointsType ?? "—"}
</td>
<td className="px-2 py-0.5">
{row.ok ? (
<span className="text-[var(--admin-success)]">
OK
</span>
) : (
<span className="text-destructive">
{row.error}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={importing}>
Cancel
</Button>
<Button onClick={handleImport} disabled={importing || validCount === 0}>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? "s" : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={importing}
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={importing || validCount === 0}
>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? "s" : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -4,235 +4,268 @@ import { ImageIcon, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface CatalogImagePickerProps {
value: string;
onChange: (name: string) => void;
/** Filter: 'header' | 'teaser' | 'all' */
type?: "header" | "teaser" | "all";
disabled?: boolean;
label?: string;
value: string;
onChange: (name: string) => void;
/** Filter: 'header' | 'teaser' | 'all' */
type?: "header" | "teaser" | "all";
disabled?: boolean;
label?: string;
}
const PAGE_SIZE = 80;
export function CatalogImagePicker({
value,
onChange,
type = "all",
disabled,
label,
value,
onChange,
type = "all",
disabled,
label,
}: CatalogImagePickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
const [images, setImages] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
const [images, setImages] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
type,
});
if (debounced) params.set("search", debounced);
const res = await fetch(`/api/admin/catalog/images?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
const fetched: string[] = data.images ?? [];
setTotal(data.total ?? 0);
setHasMore(nextOffset + fetched.length < (data.total ?? 0));
setImages((prev) => (replace ? fetched : [...prev, ...fetched]));
setOffset(nextOffset + fetched.length);
} catch {
// ignore
} finally {
setLoading(false);
}
},
[debounced, type],
);
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
type,
});
if (debounced) params.set("search", debounced);
const res = await fetch(`/api/admin/catalog/images?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
const fetched: string[] = data.images ?? [];
setTotal(data.total ?? 0);
setHasMore(nextOffset + fetched.length < (data.total ?? 0));
setImages((prev) => (replace ? fetched : [...prev, ...fetched]));
setOffset(nextOffset + fetched.length);
} catch {
// ignore
} finally {
setLoading(false);
}
},
[debounced, type],
);
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return;
setImages([]);
setOffset(0);
setHasMore(true);
fetchImages(0, true);
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [open, debounced, fetchImages]);
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return;
setImages([]);
setOffset(0);
setHasMore(true);
fetchImages(0, true);
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [open, debounced, fetchImages]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false);
}
}, [loading, hasMore, offset, fetchImages]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false);
}
}, [loading, hasMore, offset, fetchImages]);
const handleSelect = (name: string) => {
onChange(name);
setOpen(false);
};
const handleSelect = (name: string) => {
onChange(name);
setOpen(false);
};
const handleClear = () => {
onChange("");
setOpen(false);
};
const handleClear = () => {
onChange("");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto w-full justify-start gap-2 px-3 py-2"
>
{value ? (
<CatalogImageThumb name={value} size={40} />
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-muted">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 text-left">
<p className="text-xs font-medium truncate">
{label || (type === "header" ? "Headline image" : type === "teaser" ? "Teaser image" : "Image")}
</p>
<p className="text-[11px] text-muted-foreground truncate font-mono">{value || "None selected"}</p>
</div>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>
{type === "header"
? "Select Headline Image"
: type === "teaser"
? "Select Teaser Image"
: "Select Catalog Image"}
</DialogTitle>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto w-full justify-start gap-2 px-3 py-2"
>
{value ? (
<CatalogImageThumb name={value} size={40} />
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-muted">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 text-left">
<p className="text-xs font-medium truncate">
{label ||
(type === "header"
? "Headline image"
: type === "teaser"
? "Teaser image"
: "Image")}
</p>
<p className="text-[11px] text-muted-foreground truncate font-mono">
{value || "None selected"}
</p>
</div>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>
{type === "header"
? "Select Headline Image"
: type === "teaser"
? "Select Teaser Image"
: "Select Catalog Image"}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by filename..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} images
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
Clear
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by filename..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} images
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
>
Clear
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[480px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{images.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No images found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
{images.map((name) => (
<button
key={name}
type="button"
onClick={() => handleSelect(name)}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card transition-colors hover:border-primary hover:shadow-sm",
value === name && "border-primary ring-2 ring-primary/30",
)}
title={name}
>
<div className="flex h-24 items-center justify-center bg-gradient-to-b from-muted/30 to-muted/60 p-1">
<CatalogImageThumb name={name} size={80} />
</div>
<div className="border-t bg-background/95 px-2 py-1">
<p className="truncate font-mono text-[10px] text-muted-foreground group-hover:text-foreground">
{name}
</p>
</div>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[480px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{images.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">
No images found
</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2">
{images.map((name) => (
<button
key={name}
type="button"
onClick={() => handleSelect(name)}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card transition-colors hover:border-primary hover:shadow-sm",
value === name && "border-primary ring-2 ring-primary/30",
)}
title={name}
>
<div className="flex h-24 items-center justify-center bg-gradient-to-b from-muted/30 to-muted/60 p-1">
<CatalogImageThumb name={name} size={80} />
</div>
<div className="border-t bg-background/95 px-2 py-1">
<p className="truncate font-mono text-[10px] text-muted-foreground group-hover:text-foreground">
{name}
</p>
</div>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Thumbnail with gif/png fallback ─────────────────────────────
function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number }) {
const [error, setError] = useState(0);
function CatalogImageThumb({
name,
size = 60,
}: {
name: string;
size?: number;
}) {
const [error, setError] = useState(0);
// Reset error state when name changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(0), [name]);
// Reset error state when name changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(0), [name]);
if (!name || error >= 2) {
return (
<div
className="flex items-center justify-center rounded bg-muted"
style={{ width: size, height: size * 0.6 }}
>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
if (!name || error >= 2) {
return (
<div
className="flex items-center justify-center rounded bg-muted"
style={{ width: size, height: size * 0.6 }}
>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
// Try .png first, fallback to .gif
const src = error === 0 ? `/swf/c_images/catalogue/${name}.png` : `/swf/c_images/catalogue/${name}.gif`;
// Try .png first, fallback to .gif
const src =
error === 0
? `/swf/c_images/catalogue/${name}.png`
: `/swf/c_images/catalogue/${name}.gif`;
return (
<img
src={src}
alt={name}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size * 0.75 }}
onError={() => setError((e) => e + 1)}
/>
);
return (
<img
src={src}
alt={name}
className="object-contain"
style={{
imageRendering: "pixelated",
maxWidth: size,
maxHeight: size * 0.75,
}}
onError={() => setError((e) => e + 1)}
/>
);
}
+203 -169
View File
@@ -4,196 +4,230 @@ import { Loader2, Package, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface IconPickerProps {
value: number;
onChange: (iconImage: number) => void;
disabled?: boolean;
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode;
value: number;
onChange: (iconImage: number) => void;
disabled?: boolean;
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode;
}
const PAGE_SIZE = 120;
export function IconPicker({ value, onChange, disabled, children }: IconPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
const [icons, setIcons] = useState<number[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
export function IconPicker({
value,
onChange,
disabled,
children,
}: IconPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
const [icons, setIcons] = useState<number[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
});
if (debounced) params.set("search", debounced);
const res = await fetch(`/api/admin/catalog/icons?${params}`);
if (!res.ok) throw new Error("Failed to load icons");
const data = await res.json();
const fetched: number[] = data.icons ?? [];
setTotal(data.total ?? 0);
setHasMore(nextOffset + fetched.length < (data.total ?? 0));
setIcons((prev) => (replace ? fetched : [...prev, ...fetched]));
setOffset(nextOffset + fetched.length);
} catch (err) {
console.error("[IconPicker]", err);
} finally {
setLoading(false);
}
},
[debounced],
);
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(nextOffset),
});
if (debounced) params.set("search", debounced);
const res = await fetch(`/api/admin/catalog/icons?${params}`);
if (!res.ok) throw new Error("Failed to load icons");
const data = await res.json();
const fetched: number[] = data.icons ?? [];
setTotal(data.total ?? 0);
setHasMore(nextOffset + fetched.length < (data.total ?? 0));
setIcons((prev) => (replace ? fetched : [...prev, ...fetched]));
setOffset(nextOffset + fetched.length);
} catch (err) {
console.error("[IconPicker]", err);
} finally {
setLoading(false);
}
},
[debounced],
);
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return;
setIcons([]);
setOffset(0);
setHasMore(true);
fetchIcons(0, true);
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [open, debounced, fetchIcons]);
// Reset and fetch on open / search change
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!open) return;
setIcons([]);
setOffset(0);
setHasMore(true);
fetchIcons(0, true);
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [open, debounced, fetchIcons]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false);
}
}, [loading, hasMore, offset, fetchIcons]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false);
}
}, [loading, hasMore, offset, fetchIcons]);
const handleSelect = (id: number) => {
onChange(id);
setOpen(false);
};
const handleSelect = (id: number) => {
onChange(id);
setOpen(false);
};
const handleClear = () => {
onChange(0);
setOpen(false);
};
const handleClear = () => {
onChange(0);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button type="button" variant="outline" disabled={disabled} className="h-auto gap-2 px-3 py-2">
<IconPreview iconImage={value} size={24} />
<span className="font-mono text-xs text-muted-foreground">
{value > 0 ? `#${value}` : "None"}
</span>
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Select Catalog Icon</DialogTitle>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children ?? (
<Button
type="button"
variant="outline"
disabled={disabled}
className="h-auto gap-2 px-3 py-2"
>
<IconPreview iconImage={value} size={24} />
<span className="font-mono text-xs text-muted-foreground">
{value > 0 ? `#${value}` : "None"}
</span>
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Select Catalog Icon</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by icon ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} icons
</Badge>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
<Package className="mr-1.5 h-3.5 w-3.5" />
None
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by icon ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-9"
autoFocus
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Badge variant="secondary" className="shrink-0 tabular-nums">
{total} icons
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
>
<Package className="mr-1.5 h-3.5 w-3.5" />
None
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[420px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{icons.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">No icons found</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(56px,1fr))] gap-1.5">
{icons.map((id) => (
<button
key={id}
type="button"
onClick={() => handleSelect(id)}
className={cn(
"group relative flex aspect-square items-center justify-center rounded border bg-card p-1 transition-colors hover:border-primary hover:bg-accent",
value === id && "border-primary ring-2 ring-primary/30",
)}
title={`Icon #${id}`}
>
<IconPreview iconImage={id} size={36} />
<span className="absolute bottom-0 left-0 right-0 truncate bg-background/80 px-0.5 text-center font-mono text-[9px] leading-tight text-muted-foreground opacity-0 group-hover:opacity-100">
{id}
</span>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[420px] overflow-y-auto rounded-md border bg-muted/20 p-2"
>
{icons.length === 0 && !loading && (
<p className="py-16 text-center text-sm text-muted-foreground">
No icons found
</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(56px,1fr))] gap-1.5">
{icons.map((id) => (
<button
key={id}
type="button"
onClick={() => handleSelect(id)}
className={cn(
"group relative flex aspect-square items-center justify-center rounded border bg-card p-1 transition-colors hover:border-primary hover:bg-accent",
value === id && "border-primary ring-2 ring-primary/30",
)}
title={`Icon #${id}`}
>
<IconPreview iconImage={id} size={36} />
<span className="absolute bottom-0 left-0 right-0 truncate bg-background/80 px-0.5 text-center font-mono text-[9px] leading-tight text-muted-foreground opacity-0 group-hover:opacity-100">
{id}
</span>
</button>
))}
</div>
{loading && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Small preview component (inlined to avoid circular imports) ───────
function IconPreview({ iconImage, size = 24 }: { iconImage: number; size?: number }) {
const [error, setError] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage]);
function IconPreview({
iconImage,
size = 24,
}: {
iconImage: number;
size?: number;
}) {
const [error, setError] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage]);
if (error || iconImage <= 0) {
return <Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />;
}
if (error || iconImage <= 0) {
return (
<Package
className="shrink-0 text-muted-foreground"
style={{ width: size, height: size }}
/>
);
}
return (
<img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
);
return (
<img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
alt=""
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
);
}
@@ -8,200 +8,214 @@ import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { cn } from "@/lib/utils";
interface ItemsShopPreviewProps {
items: CatalogItemData[];
furniRevisionMap: Record<number, { classname: string; revision: number }>;
layout: string;
canEdit: boolean;
onItemClick?: (item: CatalogItemData) => void;
items: CatalogItemData[];
furniRevisionMap: Record<number, { classname: string; revision: number }>;
layout: string;
canEdit: boolean;
onItemClick?: (item: CatalogItemData) => void;
}
export function ItemsShopPreview({
items,
furniRevisionMap,
layout,
canEdit,
onItemClick,
items,
furniRevisionMap,
layout,
canEdit,
onItemClick,
}: ItemsShopPreviewProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No items in this page yet</p>
</div>
);
}
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Package className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No items in this page yet
</p>
</div>
);
}
const gridClass = getGridClass(layout);
const tileSize = getTileSize(layout);
const gridClass = getGridClass(layout);
const tileSize = getTileSize(layout);
return (
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn("grid gap-2", gridClass)}>
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds);
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
const points = getPointsLabel(item.pointsType);
const isClub = item.clubOnly === "1";
const isOffer = item.haveOffer === "1" && item.offerId > 0;
return (
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn("grid gap-2", gridClass)}>
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds);
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
const points = getPointsLabel(item.pointsType);
const isClub = item.clubOnly === "1";
const isOffer = item.haveOffer === "1" && item.offerId > 0;
return (
<button
key={item.id}
type="button"
onClick={() => onItemClick?.(item)}
disabled={!canEdit && !onItemClick}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
canEdit && "hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer",
!canEdit && "cursor-default",
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ""}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
return (
<button
key={item.id}
type="button"
onClick={() => onItemClick?.(item)}
disabled={!canEdit && !onItemClick}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
canEdit &&
"hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer",
!canEdit && "cursor-default",
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ""}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-warning-subtle)] px-1 text-[8px] text-[var(--admin-warning)]">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-error)] px-1 text-[8px] text-[var(--admin-error-foreground)]">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-warning-subtle)] px-1 text-[8px] text-[var(--admin-warning)]">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-error)] px-1 text-[8px] text-[var(--admin-error-foreground)]">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]">
x{item.amount}
</Badge>
)}
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]">
x{item.amount}
</Badge>
)}
{/* Limited */}
{item.limitedStack > 0 && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Limited */}
{item.limitedStack > 0 && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
{item.costCredits}c
</span>
)}
{item.costPoints > 0 && (
<span className={points.color}>
{item.costPoints} {points.label.slice(0, 3)}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
);
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
{item.costCredits}c
</span>
)}
{item.costPoints > 0 && (
<span className={points.color}>
{item.costPoints} {points.label.slice(0, 3)}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
// ── Helpers ─────────────────────────────────────────────────────
function parseFirstItemId(itemIds: string): number | null {
if (!itemIds) return null;
const first = itemIds.split(";")[0]?.trim();
const n = parseInt(first, 10);
return Number.isNaN(n) ? null : n;
if (!itemIds) return null;
const first = itemIds.split(";")[0]?.trim();
const n = parseInt(first, 10);
return Number.isNaN(n) ? null : n;
}
function getGridClass(layout: string): string {
switch (layout) {
case "spaces_new":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
case "frontpage":
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3";
case "single_bundle":
case "room_bundle":
return "grid-cols-1 sm:grid-cols-2";
case "pets":
case "pets2":
case "pets3":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
default:
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5";
}
switch (layout) {
case "spaces_new":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
case "frontpage":
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3";
case "single_bundle":
case "room_bundle":
return "grid-cols-1 sm:grid-cols-2";
case "pets":
case "pets2":
case "pets3":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
default:
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5";
}
}
function getTileSize(layout: string): number {
switch (layout) {
case "frontpage":
case "single_bundle":
case "room_bundle":
return 140;
case "spaces_new":
return 110;
default:
return 96;
}
switch (layout) {
case "frontpage":
case "single_bundle":
case "room_bundle":
return 140;
case "spaces_new":
return 110;
default:
return 96;
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({ classname, revision, size }: { classname: string; revision: number; size: number }) {
if (!classname) {
return (
<ImageIcon className="text-muted-foreground/40" style={{ width: size * 0.5, height: size * 0.5 }} />
);
}
function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string;
revision: number;
size: number;
}) {
if (!classname) {
return (
<ImageIcon
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
);
}
const primary = getLocalIconUrl(classname);
const primary = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
);
}
+213 -184
View File
@@ -5,210 +5,239 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createCatalogItem } from "@/actions/catalog-items";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
interface FurniResult {
id: number;
itemName: string;
publicName: string;
type: string;
spriteId: number;
id: number;
itemName: string;
publicName: string;
type: string;
spriteId: number;
}
interface QuickAddFurniProps {
pageId: number;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
pageId: number;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
}
export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [results, setResults] = useState<FurniResult[]>([]);
const [loading, setLoading] = useState(false);
const { run, isPending } = useServerAction();
const inputRef = useRef<HTMLInputElement>(null);
export function QuickAddFurni({
pageId,
furniRevisionMap,
}: QuickAddFurniProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [results, setResults] = useState<FurniResult[]>([]);
const [loading, setLoading] = useState(false);
const { run, isPending } = useServerAction();
const inputRef = useRef<HTMLInputElement>(null);
// Keyboard shortcut: Ctrl+K / Cmd+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
e.preventDefault();
setOpen(true);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Keyboard shortcut: Ctrl+K / Cmd+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
e.preventDefault();
setOpen(true);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50);
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50);
}, [open]);
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([]);
return;
}
setLoading(true);
const ac = new AbortController();
fetch(`/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=30`, {
signal: ac.signal,
})
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([]);
return;
}
setLoading(true);
const ac = new AbortController();
fetch(
`/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=30`,
{
signal: ac.signal,
},
)
.then((r) => r.json())
.then((data) => setResults(data.results ?? []))
.catch(() => {})
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
const handleAdd = useCallback(
(base: FurniResult) => {
run(
() =>
createCatalogItem({
pageId,
itemIds: String(base.id),
catalogName: base.publicName || base.itemName,
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: "Failed to add item.",
onSuccess: () => {
toast.success(`Added #${base.id}`);
},
},
);
},
[pageId, run],
);
const handleAdd = useCallback(
(base: FurniResult) => {
run(
() =>
createCatalogItem({
pageId,
itemIds: String(base.id),
catalogName: base.publicName || base.itemName,
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: "Failed to add item.",
onSuccess: () => {
toast.success(`Added #${base.id}`);
},
},
);
},
[pageId, run],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Zap className="mr-2 h-4 w-4" />
Quick Add
<kbd className="ml-2 hidden rounded border bg-muted px-1 text-[10px] sm:inline">Ctrl+K</kbd>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Quick Add Furni</DialogTitle>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Zap className="mr-2 h-4 w-4" />
Quick Add
<kbd className="ml-2 hidden rounded border bg-muted px-1 text-[10px] sm:inline">
Ctrl+K
</kbd>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Quick Add Furni</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search by item name or public name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
{loading && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search by item name or public name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
{loading && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="max-h-[420px] overflow-y-auto rounded-md border">
{debounced.length < 2 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
) : results.length === 0 && !loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No furni found for &quot;{debounced}&quot;
</p>
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id];
return (
<button
key={r.id}
type="button"
onClick={() => handleAdd(r)}
disabled={isPending}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent disabled:opacity-50"
>
<FurniThumb classname={furni?.classname ?? r.itemName} revision={furni?.revision ?? 0} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{r.publicName || <span className="italic text-muted-foreground">{r.itemName}</span>}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
#{r.id} · {r.itemName} · {r.type}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">Add →</span>
</button>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
<div className="max-h-[420px] overflow-y-auto rounded-md border">
{debounced.length < 2 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
) : results.length === 0 && !loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No furni found for &quot;{debounced}&quot;
</p>
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id];
return (
<button
key={r.id}
type="button"
onClick={() => handleAdd(r)}
disabled={isPending}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent disabled:opacity-50"
>
<FurniThumb
classname={furni?.classname ?? r.itemName}
revision={furni?.revision ?? 0}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{r.publicName || (
<span className="italic text-muted-foreground">
{r.itemName}
</span>
)}
</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
#{r.id} · {r.itemName} · {r.type}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
Add →
</span>
</button>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function FurniThumb({ classname, revision }: { classname: string; revision: number }) {
if (!classname) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
<Package className="h-4 w-4 text-muted-foreground" />
</div>
);
}
const primary = getLocalIconUrl(classname);
function FurniThumb({
classname,
revision,
}: {
classname: string;
revision: number;
}) {
if (!classname) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
<Package className="h-4 w-4 text-muted-foreground" />
</div>
);
}
const primary = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
}
+32 -28
View File
@@ -3,34 +3,38 @@
import type { ReactNode } from "react";
export function ConfirmAction({
message,
action,
userId,
className,
style,
children,
message,
action,
userId,
className,
style,
children,
}: {
message: string;
action: string;
userId: number;
className?: string;
style?: React.CSSProperties;
children: ReactNode;
message: string;
action: string;
userId: number;
className?: string;
style?: React.CSSProperties;
children: ReactNode;
}) {
return (
<form action="/api/admin/users/actions" method="post" style={{ display: "inline-block" }}>
<input type="hidden" name="userId" value={userId} />
<input type="hidden" name="action" value={action} />
<button
type="submit"
className={className}
style={style}
onClick={(e) => {
if (!confirm(message)) e.preventDefault();
}}
>
{children}
</button>
</form>
);
return (
<form
action="/api/admin/users/actions"
method="post"
style={{ display: "inline-block" }}
>
<input type="hidden" name="userId" value={userId} />
<input type="hidden" name="action" value={action} />
<button
type="submit"
className={className}
style={style}
onClick={(e) => {
if (!confirm(message)) e.preventDefault();
}}
>
{children}
</button>
</form>
);
}
+44 -33
View File
@@ -3,42 +3,53 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ChartDataPoint {
label: string;
value: number;
label: string;
value: number;
}
export function DashboardChart({
title,
data,
description,
title,
data,
description,
}: {
title: string;
data: ChartDataPoint[];
color?: string;
description?: string;
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}>
{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>
);
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}>
{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>
);
}
+163 -140
View File
@@ -4,172 +4,195 @@ import Image from "next/image";
import type { ReactNode } from "react";
export interface OnlineUser {
id: number;
username: string;
look: string;
roomId: number | null;
roomName: string | null;
lastActivity: Date;
id: number;
username: string;
look: string;
roomId: number | null;
roomName: string | null;
lastActivity: Date;
}
type State = "ok" | "warn" | "danger" | "neutral";
/** A single live-status tile (online users, emulator, DB, …). */
export function StatusCard({
label,
value,
hint,
state: _state = "neutral",
icon,
label,
value,
hint,
state: _state = "neutral",
icon,
}: {
label: string;
value: ReactNode;
hint?: string;
state?: State;
icon?: ReactNode;
label: string;
value: ReactNode;
hint?: string;
state?: State;
icon?: ReactNode;
}) {
return (
<div className="bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</span>
{icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 grid place-items-center text-[var(--admin-accent)] flex-none">
{icon}
</span>
) : null}
</div>
<div className="text-2xl font-extrabold text-[var(--admin-text)] leading-tight">{value}</div>
{hint ? (
<div className="text-[0.7rem] text-[var(--admin-text-muted)] mt-1.5 font-medium">{hint}</div>
) : null}
</div>
);
return (
<div className="bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</span>
{icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 grid place-items-center text-[var(--admin-accent)] flex-none">
{icon}
</span>
) : null}
</div>
<div className="text-2xl font-extrabold text-[var(--admin-text)] leading-tight">
{value}
</div>
{hint ? (
<div className="text-[0.7rem] text-[var(--admin-text-muted)] mt-1.5 font-medium">
{hint}
</div>
) : null}
</div>
);
}
/** A row in the system-diagnostics list with an ok/warn/error pill. */
export function DiagnosticRow({
label,
detail,
state,
label,
detail,
state,
}: {
label: string;
detail: string;
state: Exclude<State, "neutral">;
label: string;
detail: string;
state: Exclude<State, "neutral">;
}) {
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
const dotColor =
state === "ok"
? "bg-[var(--admin-success-subtle)] shadow-[0_0_0_3px_rgba(22,163,74,0.22)]"
: state === "warn"
? "bg-[var(--admin-accent)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]"
: "bg-[var(--admin-error-subtle)] shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor =
state === "ok"
? "bg-[var(--admin-success-subtle)]"
: state === "danger"
? "bg-[var(--admin-error-subtle)]"
: "bg-[var(--admin-accent)]/18";
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
const dotColor =
state === "ok"
? "bg-[var(--admin-success-subtle)] shadow-[0_0_0_3px_rgba(22,163,74,0.22)]"
: state === "warn"
? "bg-[var(--admin-accent)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]"
: "bg-[var(--admin-error-subtle)] shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor =
state === "ok"
? "bg-[var(--admin-success-subtle)]"
: state === "danger"
? "bg-[var(--admin-error-subtle)]"
: "bg-[var(--admin-accent)]/18";
return (
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--admin-border)] first:border-t-0">
<span className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`} aria-hidden />
<span className="font-bold text-sm">{label}</span>
<span className="flex-1 text-[0.82rem] text-[var(--admin-text-muted)]">{detail}</span>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--admin-text)]`}
>
{text}
</span>
</div>
);
return (
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--admin-border)] first:border-t-0">
<span
className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`}
aria-hidden
/>
<span className="font-bold text-sm">{label}</span>
<span className="flex-1 text-[0.82rem] text-[var(--admin-text-muted)]">
{detail}
</span>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--admin-text)]`}
>
{text}
</span>
</div>
);
}
/** Small labelled key/value used in the server-info card. */
export function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5 text-sm">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<strong>{value}</strong>
</div>
);
export function InfoItem({
label,
value,
}: {
label: string;
value: ReactNode;
}) {
return (
<div className="flex flex-col gap-0.5 text-sm">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<strong>{value}</strong>
</div>
);
}
/** Live list of online users with room and activity tracking */
export function OnlineUsersWidget({
users,
isLoading = false,
users,
isLoading = false,
}: {
users: OnlineUser[];
isLoading?: boolean;
users: OnlineUser[];
isLoading?: boolean;
}) {
const formatLastSeen = (date: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
const formatLastSeen = (date: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
return (
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">Online Now</h3>
<span className="text-sm text-[var(--admin-text-muted)] bg-[var(--admin-accent)]/10 px-3 py-1 rounded-full">
{isLoading ? "..." : users.length} users
</span>
</div>
return (
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">
Online Now
</h3>
<span className="text-sm text-[var(--admin-text-muted)] bg-[var(--admin-accent)]/10 px-3 py-1 rounded-full">
{isLoading ? "..." : users.length} users
</span>
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
{users.length === 0 ? (
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
<span className="text-[var(--admin-text-muted)] text-sm">No users online</span>
</div>
) : (
users.map((user) => (
<div
key={user.id}
className="flex items-center gap-3 p-3 bg-[var(--admin-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-0.5"
>
<div className="relative flex-none w-[50px] h-[62px]">
<Image
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
alt={user.username}
fill
className="rounded object-cover border border-[var(--admin-border)]"
unoptimized
/>
{user.roomId && user.roomName && (
<div className="absolute -bottom-0.5 -right-0.5">
<span className="block w-[10px] h-[10px] bg-[var(--admin-success-subtle)] rounded-full border-2 border-[var(--admin-surface)]" />
</div>
)}
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
{users.length === 0 ? (
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
<span className="text-[var(--admin-text-muted)] text-sm">
No users online
</span>
</div>
) : (
users.map((user) => (
<div
key={user.id}
className="flex items-center gap-3 p-3 bg-[var(--admin-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-0.5"
>
<div className="relative flex-none w-[50px] h-[62px]">
<Image
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
alt={user.username}
fill
className="rounded object-cover border border-[var(--admin-border)]"
unoptimized
/>
{user.roomId && user.roomName && (
<div className="absolute -bottom-0.5 -right-0.5">
<span className="block w-[10px] h-[10px] bg-[var(--admin-success-subtle)] rounded-full border-2 border-[var(--admin-surface)]" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
<a
href={`/u/${user.username}`}
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
>
{user.username}
</a>
</div>
<div className="text-[0.75rem] text-[var(--admin-text-muted)]">
{user.roomId && user.roomName ? (
<span className="text-[var(--admin-success)] font-medium">🏠 {user.roomName}</span>
) : (
<span className="text-[var(--admin-error)]">⏱️ {formatLastSeen(user.lastActivity)}</span>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
);
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
<a
href={`/u/${user.username}`}
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
>
{user.username}
</a>
</div>
<div className="text-[0.75rem] text-[var(--admin-text-muted)]">
{user.roomId && user.roomName ? (
<span className="text-[var(--admin-success)] font-medium">
🏠 {user.roomName}
</span>
) : (
<span className="text-[var(--admin-error)]">
⏱️ {formatLastSeen(user.lastActivity)}
</span>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
+498 -431
View File
@@ -1,16 +1,16 @@
"use client";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Download,
Filter,
Search,
SlidersHorizontal,
X,
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";
@@ -18,9 +18,26 @@ 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 {
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";
@@ -30,457 +47,507 @@ import type { DataTableColumn, PaginatedResult } from "@/types";
* keys at once; the preset is "active" when every key matches the URL state.
*/
export interface DataTablePreset {
label: string;
params: Record<string, string>;
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[];
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,
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 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 currentSort = searchParams.get("sort") || "";
const currentOrder = searchParams.get("order") || "desc";
const debouncedSearch = useDebounce(searchValue, 300);
const isFirstRender = useRef(true);
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;
// 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()}`);
});
}
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
// 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 handleSearch(value: string) {
setSearchValue(value);
}
function handleSort(key: string) {
const newOrder = currentSort === key && currentOrder === "asc" ? "desc" : "asc";
updateParams({ sort: key, order: newOrder });
}
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 handlePageChange(page: number) {
updateParams({ page: String(page) });
}
function handlePerPageChange(perPage: string) {
updateParams({ perPage, page: "1" });
}
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 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 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 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;
});
}
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();
}
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 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 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);
}
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>
)}
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>
{/* 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>
)}
{/* 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>
)}
{/* 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>
{/* 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>
<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>
)}
{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>
)}
{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>
<div className="ml-auto text-sm text-muted-foreground">
{data.total} results
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<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>
{/* Table */}
<div className="rounded-md border">
<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>
);
{/* 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>
);
}
+381 -353
View File
@@ -1,14 +1,14 @@
"use client";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
RotateCcw,
Save,
Search,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
RotateCcw,
Save,
Search,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
@@ -17,387 +17,415 @@ 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
interface Setting {
key: string;
value: string;
key: string;
value: string;
}
interface GroupSummary {
name: string;
count: number;
name: string;
count: number;
}
interface EmulatorSettingsFormProps {
settings: Setting[];
pagination: { total: number; page: number; perPage: number; lastPage: number };
search: string;
allGroups?: GroupSummary[];
activeGroup?: string;
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 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",
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,
settings,
pagination,
search,
allGroups,
activeGroup,
}: EmulatorSettingsFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
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);
// `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]);
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]);
// 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 toggleGroup(name: string) {
updateParams({
group: activeGroup === name ? "" : name,
page: "1",
});
}
function getDisplayValue(setting: Setting): string {
return values[setting.key] ?? setting.value;
}
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 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 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 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 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 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) });
}
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>
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>
<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>
)}
{/* 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>
{/* 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>
{/* 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>
);
<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>
);
}
+122 -114
View File
@@ -6,131 +6,139 @@ import { uploadMedia } 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;
if (!url.startsWith("/")) return "";
if (/[<>"']/.test(url)) return "";
if (/(data|javascript|vbscript|file):/i.test(url)) return "";
return url;
}
export function AdminMediaGrid() {
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const fileRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
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);
}, []);
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]);
useEffect(() => {
load();
}, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setProgress({ current: 0, total: files.length });
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setProgress({ current: 0, total: files.length });
const fileArray = Array.from(files);
const fileArray = Array.from(files);
await Promise.all(
fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
);
await Promise.all(
fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
);
await load();
setUploading(false);
setProgress({ current: 0, total: 0 });
if (fileRef.current) fileRef.current.value = "";
}
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).catch(() => {});
}
function copyUrl(url: string) {
navigator.clipboard.writeText(window.location.origin + url).catch(() => {});
}
return (
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<strong>
{files.length} image{files.length !== 1 ? "s" : ""}
</strong>
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
multiple
className="hidden"
/>
</label>
</div>
return (
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<strong>
{files.length} image{files.length !== 1 ? "s" : ""}
</strong>
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
multiple
className="hidden"
/>
</label>
</div>
{uploading && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</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>
)}
{uploading && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</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>
)}
{loading ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">No images yet. Upload one above.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => (
<div
key={f.name}
className="border border-[var(--admin-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--admin-canvas)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={validImageUrl(f.url)} alt={f.name} className="w-full h-[120px] object-cover block" />
<div className="p-2">
<p className="text-xs text-[var(--admin-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</p>
<button
type="button"
onClick={() => copyUrl(f.url)}
className="btn btn-outline text-xs p-1 w-full"
>
Copy URL
</button>
</div>
</div>
))}
</div>
)}
</div>
);
{loading ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">
Loading…
</p>
) : files.length === 0 ? (
<p className="text-xs theme-text-muted dark:theme-text-muted">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => (
<div
key={f.name}
className="border border-[var(--admin-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--admin-canvas)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[120px] object-cover block"
/>
<div className="p-2">
<p className="text-xs text-[var(--admin-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</p>
<button
type="button"
onClick={() => copyUrl(f.url)}
className="btn btn-outline text-xs p-1 w-full"
>
Copy URL
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+162 -144
View File
@@ -6,155 +6,173 @@ 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;
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);
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);
}, []);
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]);
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 = "";
}
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>
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 && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div
onClick={(e) => e.stopPropagation()}
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>
)}
</>
);
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div
onClick={(e) => e.stopPropagation()}
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>
)}
</>
);
}
+23 -15
View File
@@ -1,21 +1,29 @@
import type { LucideIcon } from "lucide-react";
interface AdminPageHeaderProps {
icon: LucideIcon;
title: string;
subtitle: string;
icon: LucideIcon;
title: string;
subtitle: string;
}
export function AdminPageHeader({ icon: Icon, title, subtitle }: AdminPageHeaderProps) {
return (
<div className="flex items-center gap-3 mb-6">
<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">
<Icon size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{title}</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{subtitle}</p>
</div>
</div>
);
export function AdminPageHeader({
icon: Icon,
title,
subtitle,
}: AdminPageHeaderProps) {
return (
<div className="flex items-center gap-3 mb-6">
<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">
<Icon size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{title}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{subtitle}
</p>
</div>
</div>
);
}
+78 -65
View File
@@ -1,80 +1,93 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface PeakHoursData {
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
grid: number[][];
max: number;
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
grid: number[][];
max: number;
}
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
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))]";
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 }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
<p className="text-xs text-muted-foreground">User activity by day &amp; hour (last 7 days)</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} 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">
{day} {hour}:00 — {count} users
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
))}
{/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">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">More</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
<p className="text-xs text-muted-foreground">
User activity by day &amp; hour (last 7 days)
</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} 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">
{day} {hour}:00 — {count} users
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
))}
{/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">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">More</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+42 -32
View File
@@ -3,39 +3,49 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface RevenueDataPoint {
label: string;
credits: number;
points: number;
label: string;
credits: number;
points: number;
}
export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
const max = Math.max(1, ...data.flatMap((point) => [point.credits, point.points]));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Catalog Spending</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={`${point.credits} credits`}
/>
<div
className="h-2 rounded bg-[var(--admin-accent)]"
style={{ width: `${Math.max(2, (point.points / max) * 100)}%` }}
title={`${point.points} points`}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
const max = Math.max(
1,
...data.flatMap((point) => [point.credits, point.points]),
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Catalog Spending</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={`${point.credits} credits`}
/>
<div
className="h-2 rounded bg-[var(--admin-accent)]"
style={{
width: `${Math.max(2, (point.points / max) * 100)}%`,
}}
title={`${point.points} points`}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+103 -96
View File
@@ -8,43 +8,45 @@ import { useEffect, useId, useRef, useState } from "react";
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;
init: (opts: Record<string, unknown>) => Promise<unknown>;
remove: (selector: string) => void;
};
declare global {
interface Window {
tinymce?: TinyMCEGlobal;
}
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;
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;
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;
}
/**
@@ -53,79 +55,84 @@ function loadTinyMce(): Promise<TinyMCEGlobal | null> {
* fails to load (offline, CDN blocked) it silently degrades to the textarea.
*/
export function RichText({
name,
defaultValue = "",
placeholder,
rows = 10,
name,
defaultValue = "",
placeholder,
rows = 10,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
rows?: number;
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);
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}`;
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 */
});
});
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 () => {
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}
/>
);
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}
/>
);
}
+66 -53
View File
@@ -3,65 +3,78 @@ import Link from "next/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;
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>
);
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>;
}
if (href) {
return <Link href={href}>{content}</Link>;
}
return content;
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>
);
}
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>
);
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>
);
}
+38 -36
View File
@@ -4,42 +4,44 @@ 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);
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>
);
}
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)" }}
autoFocus
/>
<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>
);
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>
);
}
+48 -40
View File
@@ -7,48 +7,56 @@ import { toggleUserWatch } from "@/actions/watch";
import { Button } from "@/components/ui/button";
interface UserWatchToggleProps {
targetUserId: number;
username: string;
initialWatching: boolean;
targetUserId: number;
username: string;
initialWatching: boolean;
}
export function UserWatchToggle({ targetUserId, username, initialWatching }: UserWatchToggleProps) {
const [watching, setWatching] = useState(initialWatching);
const [busy, setBusy] = useState(false);
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);
}
}
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>
);
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>
);
}