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>
);
}
+82 -66
View File
@@ -4,75 +4,91 @@ import { useState } from "react";
import { linkDiscordId } from "@/actions/link-discord";
export function DiscordVerifyForm() {
const [discordId, setDiscordId] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [success, setSuccess] = useState(false);
const [discordId, setDiscordId] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
setIsPending(true);
try {
const result = await linkDiscordId(discordId);
if (result === null) {
setSuccess(true);
} else {
setError(result);
}
} catch {
setError("Er is een fout opgetreden");
} finally {
setIsPending(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
setIsPending(true);
try {
const result = await linkDiscordId(discordId);
if (result === null) {
setSuccess(true);
} else {
setError(result);
}
} catch {
setError("Er is een fout opgetreden");
} finally {
setIsPending(false);
}
}
if (success) {
return (
<div className="rounded-md p-4 text-center" style={{ backgroundColor: "#14532d", color: "white" }}>
<p className="text-lg font-bold mb-1">Geverifieerd!</p>
<p className="text-sm opacity-90">Je Discord account is gekoppeld en je account is geverifieerd.</p>
</div>
);
}
if (success) {
return (
<div
className="rounded-md p-4 text-center"
style={{ backgroundColor: "#14532d", color: "white" }}
>
<p className="text-lg font-bold mb-1">Geverifieerd!</p>
<p className="text-sm opacity-90">
Je Discord account is gekoppeld en je account is geverifieerd.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{error && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{error}
</div>
)}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{error && (
<div
className="p-3 rounded-md text-sm"
style={{ backgroundColor: "#ef4444", color: "white" }}
>
{error}
</div>
)}
<p className="text-sm" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
Open Discord, ga naar <strong>Instellingen → Geavanceerd → Ontwikkelaarsmodus</strong> (aan). Klik met
rechts op je eigen naam en kies <strong>ID kopiëren</strong>. Plak dat hier:
</p>
<p
className="text-sm"
style={{
color: "var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
Open Discord, ga naar{" "}
<strong>Instellingen → Geavanceerd → Ontwikkelaarsmodus</strong> (aan).
Klik met rechts op je eigen naam en kies <strong>ID kopiëren</strong>.
Plak dat hier:
</p>
<div className="flex gap-2">
<input
type="text"
placeholder="Plak je Discord ID hier"
value={discordId}
onChange={(e) => setDiscordId(e.target.value)}
className="flex-1 focus:ring-0 border-4 rounded text-sm px-3 py-2"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="submit"
disabled={isPending}
className="rounded px-4 py-2 text-white font-semibold whitespace-nowrap"
style={{ backgroundColor: "#5865F2", border: "2px solid #7983F5" }}
>
{isPending ? "Bezig..." : "Verifiëren"}
</button>
</div>
</form>
);
<div className="flex gap-2">
<input
type="text"
placeholder="Plak je Discord ID hier"
value={discordId}
onChange={(e) => setDiscordId(e.target.value)}
className="flex-1 focus:ring-0 border-4 rounded text-sm px-3 py-2"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
<button
type="submit"
disabled={isPending}
className="rounded px-4 py-2 text-white font-semibold whitespace-nowrap"
style={{ backgroundColor: "#5865F2", border: "2px solid #7983F5" }}
>
{isPending ? "Bezig..." : "Verifiëren"}
</button>
</div>
</form>
);
}
+108 -97
View File
@@ -5,103 +5,114 @@ import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
export function HomeLoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError("Invalid username or password");
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
}
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError("Invalid username or password");
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
);
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
}
return (
<form
onSubmit={onSubmit}
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
{needs2fa ? (
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">2FA Code</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
autoFocus
/>
</fieldset>
) : null}
{error ? <p className="m-0 text-center text-sm text-red-600 dark:text-red-400">{error}</p> : null}
<button
type="submit"
disabled={pending}
className="w-full cursor-pointer rounded-md border-2 p-2 text-base font-semibold text-white transition-colors disabled:opacity-50"
style={{
backgroundColor: "#27a44d",
borderColor: "#34d399",
}}
>
{pending ? "Please wait..." : needs2fa ? "Verify" : "Login"}
</button>
</form>
);
return (
<form
onSubmit={onSubmit}
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
{needs2fa ? (
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
2FA Code
</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
/>
</fieldset>
) : null}
{error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">
{error}
</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full cursor-pointer rounded-md border-2 p-2 text-base font-semibold text-white transition-colors disabled:opacity-50"
style={{
backgroundColor: "#27a44d",
borderColor: "#34d399",
}}
>
{pending ? "Please wait..." : needs2fa ? "Verify" : "Login"}
</button>
</form>
);
}
+289 -237
View File
@@ -1,256 +1,308 @@
"use client";
import { useActionState, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useActionState, useState } from "react";
import { register } from "@/actions/register";
interface RegisterFormProps {
hotelName: string;
captcha: { provider: string; siteKey?: string };
error?: string;
hotelName: string;
captcha: { provider: string; siteKey?: string };
error?: string;
}
export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [serverError, formAction, isPending] = useActionState(register, null);
const [termsAccepted, setTermsAccepted] = useState(false);
const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [serverError, formAction, isPending] = useActionState(register, null);
const [termsAccepted, setTermsAccepted] = useState(false);
return (
<div className="mx-auto w-full max-w-[800px]">
{/* Header Banner */}
<div
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
style={{
backgroundImage: "url(/assets/images/mybobba_banner.png)",
height: "180px",
maxHeight: "150px",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
<div className="relative flex items-center justify-center w-full h-full z-20">
<div className="relative" style={{ width: "140px", height: "140px" }}>
<div className="absolute inset-0 rounded-full overflow-hidden" style={{ zIndex: -1 }}>
<div
className="w-full h-full"
style={{ background: "black", filter: "blur(8px)", transform: "scale(1.2)", opacity: 0.6 }}
/>
</div>
<Image
src="/assets/images/FrankwithBag.gif"
alt=""
fill
className="!static"
style={{ objectFit: "contain", objectPosition: "center", width: "100%", height: "100%" }}
/>
</div>
</div>
</div>
return (
<div className="mx-auto w-full max-w-[800px]">
{/* Header Banner */}
<div
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
style={{
backgroundImage: "url(/assets/images/mybobba_banner.png)",
height: "180px",
maxHeight: "150px",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
<div className="relative flex items-center justify-center w-full h-full z-20">
<div className="relative" style={{ width: "140px", height: "140px" }}>
<div
className="absolute inset-0 rounded-full overflow-hidden"
style={{ zIndex: -1 }}
>
<div
className="w-full h-full"
style={{
background: "black",
filter: "blur(8px)",
transform: "scale(1.2)",
opacity: 0.6,
}}
/>
</div>
<Image
src="/assets/images/FrankwithBag.gif"
alt=""
fill
className="!static"
style={{
objectFit: "contain",
objectPosition: "center",
width: "100%",
height: "100%",
}}
/>
</div>
</div>
</div>
{/* Form */}
<form
action={formAction}
className="p-4 flex flex-col gap-6"
style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
borderRadius: "0 0 8px 8px",
}}
>
{error && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{error}
</div>
)}
{serverError && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{serverError}
</div>
)}
{/* Form */}
<form
action={formAction}
className="p-4 flex flex-col gap-6"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
borderRadius: "0 0 8px 8px",
}}
>
{error && (
<div
className="p-3 rounded-md text-sm"
style={{ backgroundColor: "#ef4444", color: "white" }}
>
{error}
</div>
)}
{serverError && (
<div
className="p-3 rounded-md text-sm"
style={{ backgroundColor: "#ef4444", color: "white" }}
>
{serverError}
</div>
)}
{/* Username & Email */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="username"
className="block font-semibold text-sm"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="mail"
className="block font-semibold text-sm"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</fieldset>
</div>
<p
className="text-xs mt-1"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
Geen e-mail? Je kunt later via Discord verifiëren.
</p>
</div>
{/* Username & Email */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="username"
className="block font-semibold text-sm"
style={{
color: "var(--color-text-readable, var(--color-text))",
}}
>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="mail"
className="block font-semibold text-sm"
style={{
color: "var(--color-text-readable, var(--color-text))",
}}
>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</fieldset>
</div>
<p
className="text-xs mt-1"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
Geen e-mail? Je kunt later via Discord verifiëren.
</p>
</div>
{/* Password & Confirm */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="password"
className="block font-semibold text-sm"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("password")}
</label>
<input
id="password"
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="password_confirmation"
className="block font-semibold text-sm"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("confirmPassword")}
</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
</div>
{/* Password & Confirm */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="password"
className="block font-semibold text-sm"
style={{
color: "var(--color-text-readable, var(--color-text))",
}}
>
{t("password")}
</label>
<input
id="password"
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label
htmlFor="password_confirmation"
className="block font-semibold text-sm"
style={{
color: "var(--color-text-readable, var(--color-text))",
}}
>
{t("confirmPassword")}
</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable, var(--color-text))",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
/>
</fieldset>
</div>
</div>
{/* Terms */}
<div
className="rounded-md p-3 flex flex-col gap-3"
style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
}}
>
<div className="flex items-center gap-3 text-sm">
<button
type="button"
role="checkbox"
aria-checked={termsAccepted}
onClick={() => setTermsAccepted(!termsAccepted)}
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
style={{
borderColor: termsAccepted
? "#27a44d"
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
backgroundColor: termsAccepted ? "#27a44d" : "transparent",
}}
>
{termsAccepted && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<input type="hidden" name="terms" value={termsAccepted ? "1" : ""} />
<span
className="font-semibold cursor-pointer"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
onClick={() => setTermsAccepted(!termsAccepted)}
>
{t("termsAccept", { hotel: hotelName })}
</span>
</div>
</div>
{/* Terms */}
<div
className="rounded-md p-3 flex flex-col gap-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
}}
>
<div className="flex items-center gap-3 text-sm">
<button
type="button"
role="checkbox"
aria-checked={termsAccepted}
onClick={() => setTermsAccepted(!termsAccepted)}
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
style={{
borderColor: termsAccepted
? "#27a44d"
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
backgroundColor: termsAccepted ? "#27a44d" : "transparent",
}}
>
{termsAccepted && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<input
type="hidden"
name="terms"
value={termsAccepted ? "1" : ""}
/>
<span
className="font-semibold cursor-pointer"
style={{ color: "var(--color-text-readable, var(--color-text))" }}
onClick={() => setTermsAccepted(!termsAccepted)}
>
{t("termsAccept", { hotel: hotelName })}
</span>
</div>
</div>
{/* Captcha */}
{showCaptcha && captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{showCaptcha && captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{/* Captcha */}
{showCaptcha && captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{showCaptcha && captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
style={{
backgroundColor: "#27a44d",
border: "2px solid #34c661",
}}
>
{isPending ? t("creatingAccount") : t("createAccount")}
</button>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
style={{
backgroundColor: "#27a44d",
border: "2px solid #34c661",
}}
>
{isPending ? t("creatingAccount") : t("createAccount")}
</button>
<div className="text-center">
<Link
href="/login"
className="text-sm font-semibold hover:underline"
style={{ color: "var(--color-primary-readable, var(--color-primary))" }}
>
{t("alreadyHaveAccount")}
</Link>
</div>
</form>
</div>
);
<div className="text-center">
<Link
href="/login"
className="text-sm font-semibold hover:underline"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("alreadyHaveAccount")}
</Link>
</div>
</form>
</div>
);
}
+10 -7
View File
@@ -5,11 +5,14 @@ import { describe, expect, it } from "vitest";
import { CmsInfoPopup } from "@/components/cms-info-popup";
describe("CmsInfoPopup", () => {
it("renders the footer trigger", () => {
const html = renderToStaticMarkup(<CmsInfoPopup />);
const source = readFileSync(resolve(process.cwd(), "src/components/cms-info-popup.tsx"), "utf8");
expect(html).toContain("EpicNext Info");
expect(source).toContain(">EpicNext<");
expect(source).toContain("Modern Next.js CMS");
});
it("renders the footer trigger", () => {
const html = renderToStaticMarkup(<CmsInfoPopup />);
const source = readFileSync(
resolve(process.cwd(), "src/components/cms-info-popup.tsx"),
"utf8",
);
expect(html).toContain("EpicNext Info");
expect(source).toContain(">EpicNext<");
expect(source).toContain("Modern Next.js CMS");
});
});
+76 -67
View File
@@ -3,80 +3,89 @@
import { useEffect, useState } from "react";
const cmsDetails = [
["Edition", "EpicNext"],
["Interface", "Modern CMS"],
["Runtime", "Next.js App Router"],
["Edition", "EpicNext"],
["Interface", "Modern CMS"],
["Runtime", "Next.js App Router"],
];
const credits = [
["Remco", "EpicNext direction and development"],
["Life", "Next.js architecture and CMS refinements"],
["Remco", "EpicNext direction and development"],
["Life", "Next.js architecture and CMS refinements"],
];
export function CmsInfoPopup() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (window.location.hash === "#cms-info") setOpen(true);
}, []);
useEffect(() => {
if (window.location.hash === "#cms-info") setOpen(true);
}, []);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
return (
<>
<button type="button" className="cms-info-trigger" onClick={() => setOpen(true)}>
EpicNext Info
</button>
{open ? (
<div className="cms-info-backdrop" role="presentation" onMouseDown={() => setOpen(false)}>
<section
aria-labelledby="cms-info-title"
aria-modal="true"
className="cms-info-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
>
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={() => setOpen(false)}
>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast navigation, clean administration,
and a coherent EpicNext experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</div>
</section>
</div>
) : null}
</>
);
return (
<>
<button
type="button"
className="cms-info-trigger"
onClick={() => setOpen(true)}
>
EpicNext Info
</button>
{open ? (
<div
className="cms-info-backdrop"
role="presentation"
onMouseDown={() => setOpen(false)}
>
<section
aria-labelledby="cms-info-title"
aria-modal="true"
className="cms-info-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
>
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={() => setOpen(false)}
>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast
navigation, clean administration, and a coherent EpicNext
experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</div>
</section>
</div>
) : null}
</>
);
}
+230 -199
View File
@@ -1,212 +1,243 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HomeLoginForm } from "@/components/auth/home-login-form";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { HomeLoginForm } from "@/components/auth/home-login-form";
export default async function GuestView() {
const t = await getTranslations("pages.home");
const _hotelName = await siteSettings.get("hotel_name", "Atom");
const imager =
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
const t = await getTranslations("pages.home");
const _hotelName = await siteSettings.get("hotel_name", "Atom");
const imager =
(await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
try {
articles = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 8,
select: { slug: true, title: true, shortStory: true, image: true },
});
} catch {}
let articles: Array<{
slug: string;
title: string;
shortStory: string;
image: string;
}> = [];
try {
articles = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 8,
select: { slug: true, title: true, shortStory: true, image: true },
});
} catch {}
let recentUsers: Array<{ username: string; look: string }> = [];
try {
recentUsers = await prisma.user.findMany({
orderBy: { lastOnline: "desc" },
take: 12,
select: { username: true, look: true },
});
} catch {}
let recentUsers: Array<{ username: string; look: string }> = [];
try {
recentUsers = await prisma.user.findMany({
orderBy: { lastOnline: "desc" },
take: 12,
select: { username: true, look: true },
});
} catch {}
return (
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
>
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
}}
/>
<div
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
</div>
<div className="p-3" style={{ backgroundColor: "var(--color-surface)" }}>
<HomeLoginForm />
</div>
</div>
return (
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
}}
/>
<div
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
</div>
<div
className="p-3"
style={{ backgroundColor: "var(--color-surface)" }}
>
<HomeLoginForm />
</div>
</div>
<a href="/register" className="relative block">
<div
className="inline-block overflow-hidden rounded-lg border"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
>
<Image
src="/assets/images/EnterHubbly.png"
alt="Register"
width={300}
height={100}
className="block"
unoptimized
/>
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white text-shadow"
style={{ marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
<a href="/register" className="relative block">
<div
className="inline-block overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt="Register"
width={300}
height={100}
className="block"
unoptimized
/>
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white text-shadow"
style={{ marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
{recentUsers.length > 0 && (
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background:
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3 p-3">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md p-0 shadow-lg"
style={{
width: "50px",
height: "50px",
justifySelf: "center",
backgroundColor: "var(--color-surface)",
}}
>
<Image
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
alt={u.username}
width={50}
height={62}
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
maxWidth: "none",
maxHeight: "none",
}}
unoptimized
/>
</div>
))}
</div>
</div>
)}
</div>
{recentUsers.length > 0 && (
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background:
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">
Recent Users
</h2>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3 p-3">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md p-0 shadow-lg"
style={{
width: "50px",
height: "50px",
justifySelf: "center",
backgroundColor: "var(--color-surface)",
}}
>
<Image
src={avatarImageUrl(imager, u.look, {
headOnly: true,
direction: 3,
})}
alt={u.username}
width={50}
height={62}
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
maxWidth: "none",
maxHeight: "none",
}}
unoptimized
/>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex w-full flex-col gap-4 md:w-2/3">
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">{t("latestNews") || "Latest News"}</h2>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p
className="py-8 text-center"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{t("noArticles")}
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<Image
src={a.image}
alt={a.title}
fill
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
unoptimized
/>
<div
className="absolute left-0 w-full p-2"
style={{
bottom: "-2px",
background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
}}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">
{a.title}
</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
<div className="flex w-full flex-col gap-4 md:w-2/3">
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">
{t("latestNews") || "Latest News"}
</h2>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p
className="py-8 text-center"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{t("noArticles")}
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<Image
src={a.image}
alt={a.title}
fill
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
unoptimized
/>
<div
className="absolute left-0 w-full p-2"
style={{
bottom: "-2px",
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
}}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">
{a.title}
</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+222 -188
View File
@@ -1,202 +1,236 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
interface UserViewProps {
userId: number;
username: string;
look: string;
userId: number;
username: string;
look: string;
}
export default async function UserView({ userId, username, look }: UserViewProps) {
const t = await getTranslations("pages.home");
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const imager =
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
export default async function UserView({
userId,
username,
look,
}: UserViewProps) {
const t = await getTranslations("pages.home");
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const imager =
(await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
let onlineFriends: Array<{ username: string; look: string }> = [];
try {
const friendships = await prisma.messengerFriendships.findMany({
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
select: { userOneId: true, userTwoId: true },
});
const friendIds = friendships.map((f) => (f.userOneId === userId ? f.userTwoId : f.userOneId));
if (friendIds.length > 0) {
onlineFriends = await prisma.user.findMany({
where: { id: { in: friendIds }, online: "1" },
take: 10,
select: { username: true, look: true },
});
}
} catch {}
let onlineFriends: Array<{ username: string; look: string }> = [];
try {
const friendships = await prisma.messengerFriendships.findMany({
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
select: { userOneId: true, userTwoId: true },
});
const friendIds = friendships.map((f) =>
f.userOneId === userId ? f.userTwoId : f.userOneId,
);
if (friendIds.length > 0) {
onlineFriends = await prisma.user.findMany({
where: { id: { in: friendIds }, online: "1" },
take: 10,
select: { username: true, look: true },
});
}
} catch {}
const [latestArticle] = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
take: 1,
select: { slug: true, title: true, shortStory: true, image: true },
})
.catch(() => []);
const [latestArticle] = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
take: 1,
select: { slug: true, title: true, shortStory: true, image: true },
})
.catch(() => []);
return (
<div className="flex flex-col gap-6 md:flex-row">
<div className="flex w-full flex-col gap-6 md:w-3/4">
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
}}
/>
<div
className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
<Image
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
alt={username}
width={64}
height={110}
className="drop-shadow-2xl"
style={{ imageRendering: "pixelated" }}
unoptimized
/>
</Link>
<div className="flex items-center mb-4">
<Link href="/client" target="_blank" rel="noopener noreferrer" prefetch={true}>
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color: "var(--button-text-color-readable, var(--button-text-color))",
border: "1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
{t("enterHotel", { hotelName })}
<svg
className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</button>
</Link>
</div>
</div>
</div>
return (
<div className="flex flex-col gap-6 md:flex-row">
<div className="flex w-full flex-col gap-6 md:w-3/4">
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
}}
/>
<div
className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
<Link
href={`/u/${username}`}
className="transition-transform duration-300 hover:scale-105 -mb-8"
>
<Image
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
alt={username}
width={64}
height={110}
className="drop-shadow-2xl"
style={{ imageRendering: "pixelated" }}
unoptimized
/>
</Link>
<div className="flex items-center mb-4">
<Link
href="/client"
target="_blank"
rel="noopener noreferrer"
prefetch={true}
>
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color))",
border:
"1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
{t("enterHotel", { hotelName })}
<svg
className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</Link>
</div>
</div>
</div>
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "#e9b124" }}
>
<span className="relative text-white">{t("onlineFriends")}</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p
className="mb-3 block w-full text-center text-xs font-medium md:mb-0 md:text-left"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<Image
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
width={40}
height={40}
className="h-10 w-10"
title={f.username}
unoptimized
/>
</Link>
))
)}
</div>
</div>
</div>
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "#e9b124" }}
>
<span className="relative text-white">{t("onlineFriends")}</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p
className="mb-3 block w-full text-center text-xs font-medium md:mb-0 md:text-left"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<Image
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
width={40}
height={40}
className="h-10 w-10"
title={f.username}
unoptimized
/>
</Link>
))
)}
</div>
</div>
</div>
<div className="flex w-full flex-col gap-6 md:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">{t("latestArticle")}</h2>
</div>
<Link href={`/news/${latestArticle.slug}`} className="group block">
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={latestArticle.image}
alt={latestArticle.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
unoptimized
/>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p
className="mt-1 text-xs"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
</div>
</Link>
</div>
) : null}
</div>
</div>
);
<div className="flex w-full flex-col gap-6 md:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">
{t("latestArticle")}
</h2>
</div>
<Link href={`/news/${latestArticle.slug}`} className="group block">
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={latestArticle.image}
alt={latestArticle.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
unoptimized
/>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p
className="mt-1 text-xs"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
</div>
</Link>
</div>
) : null}
</div>
</div>
);
}
+160 -144
View File
@@ -1,159 +1,175 @@
"use client";
import Image from "next/image";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition, useState, useRef, useEffect, useMemo } from "react";
import { Search } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useLocale } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
const LOCALES: { code: string; label: string; lang: string }[] = [
{ code: "nl", label: "Nederlands", lang: "nl" },
{ code: "en", label: "English", lang: "en" },
{ code: "de", label: "Deutsch", lang: "de" },
{ code: "fr", label: "Français", lang: "fr" },
{ code: "es", label: "Español", lang: "es" },
{ code: "it", label: "Italiano", lang: "it" },
{ code: "pt", label: "Português", lang: "pt" },
{ code: "pl", label: "Polski", lang: "pl" },
{ code: "sv", label: "Svenska", lang: "sv" },
{ code: "tr", label: "Türkçe", lang: "tr" },
{ code: "ro", label: "Română", lang: "ro" },
{ code: "hu", label: "Magyar", lang: "hu" },
{ code: "cs", label: "Čeština", lang: "cs" },
{ code: "sk", label: "Slovenčina", lang: "sk" },
{ code: "da", label: "Dansk", lang: "da" },
{ code: "no", label: "Norsk", lang: "no" },
{ code: "el", label: "Ελληνικά", lang: "el" },
{ code: "bg", label: "Български", lang: "bg" },
{ code: "hr", label: "Hrvatski", lang: "hr" },
{ code: "sr", label: "Српски", lang: "sr" },
{ code: "uk", label: "Українська", lang: "uk" },
{ code: "ru", label: "Русский", lang: "ru" },
{ code: "nl", label: "Nederlands", lang: "nl" },
{ code: "en", label: "English", lang: "en" },
{ code: "de", label: "Deutsch", lang: "de" },
{ code: "fr", label: "Français", lang: "fr" },
{ code: "es", label: "Español", lang: "es" },
{ code: "it", label: "Italiano", lang: "it" },
{ code: "pt", label: "Português", lang: "pt" },
{ code: "pl", label: "Polski", lang: "pl" },
{ code: "sv", label: "Svenska", lang: "sv" },
{ code: "tr", label: "Türkçe", lang: "tr" },
{ code: "ro", label: "Română", lang: "ro" },
{ code: "hu", label: "Magyar", lang: "hu" },
{ code: "cs", label: "Čeština", lang: "cs" },
{ code: "sk", label: "Slovenčina", lang: "sk" },
{ code: "da", label: "Dansk", lang: "da" },
{ code: "no", label: "Norsk", lang: "no" },
{ code: "el", label: "Ελληνικά", lang: "el" },
{ code: "bg", label: "Български", lang: "bg" },
{ code: "hr", label: "Hrvatski", lang: "hr" },
{ code: "sr", label: "Српски", lang: "sr" },
{ code: "uk", label: "Українська", lang: "uk" },
{ code: "ru", label: "Русский", lang: "ru" },
];
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0];
const locale = useLocale();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0];
const filtered = useMemo(() => {
if (!search) return LOCALES;
const q = search.toLowerCase();
return LOCALES.filter(
(l) =>
l.label.toLowerCase().includes(q) ||
l.code.toLowerCase().includes(q) ||
l.lang.toLowerCase().includes(q),
);
}, [search]);
const filtered = useMemo(() => {
if (!search) return LOCALES;
const q = search.toLowerCase();
return LOCALES.filter(
(l) =>
l.label.toLowerCase().includes(q) ||
l.code.toLowerCase().includes(q) ||
l.lang.toLowerCase().includes(q),
);
}, [search]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
function switchLocale(code: string) {
if (code === locale) return;
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=strict;secure`;
startTransition(() => router.refresh());
setOpen(false);
}
function switchLocale(code: string) {
if (code === locale) return;
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=strict;secure`;
startTransition(() => router.refresh());
setOpen(false);
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
disabled={pending}
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
>
<Image
src={`/assets/images/icons/flags/${current.code}.png`}
alt={current.lang}
width={16}
height={16}
className="inline-block h-4 w-auto"
/>
<span>{current.code.toUpperCase()}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
disabled={pending}
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
>
<Image
src={`/assets/images/icons/flags/${current.code}.png`}
alt={current.lang}
width={16}
height={16}
className="inline-block h-4 w-auto"
/>
<span>{current.code.toUpperCase()}</span>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{open && (
<div className="dropdown-menu absolute left-0 md:right-0 md:left-auto top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2">
<div className="px-3 pb-1">
<div className="flex items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 text-sm">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
style={{ border: "none", padding: 0, margin: 0 }}
autoFocus
/>
</div>
</div>
<div className="max-h-[260px] overflow-y-auto">
{filtered.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">Geen talen gevonden</p>
)}
{filtered.map((l) => (
<button
key={l.code}
type="button"
onClick={() => switchLocale(l.code)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm font-medium transition-all duration-100 rounded-lg mx-1 ${
l.code === locale ? "opacity-100" : "opacity-70 hover:opacity-100"
}`}
style={{
color: "var(--color-text-readable, var(--color-text))",
background:
l.code === locale ? "color-mix(in srgb, var(--color-primary) 10%, transparent)" : "none",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-primary) 10%, transparent)";
e.currentTarget.style.color = "var(--color-primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
l.code === locale
? "color-mix(in srgb, var(--color-primary) 10%, transparent)"
: "transparent";
e.currentTarget.style.color = "var(--color-text)";
}}
>
<Image
src={`/assets/images/icons/flags/${l.code}.png`}
alt={l.lang}
width={16}
height={16}
className="inline-block h-4 w-auto"
/>
{l.label}
</button>
))}
</div>
</div>
)}
</div>
);
{open && (
<div className="dropdown-menu absolute left-0 md:right-0 md:left-auto top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2">
<div className="px-3 pb-1">
<div className="flex items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 text-sm">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
style={{ border: "none", padding: 0, margin: 0 }}
/>
</div>
</div>
<div className="max-h-[260px] overflow-y-auto">
{filtered.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
Geen talen gevonden
</p>
)}
{filtered.map((l) => (
<button
key={l.code}
type="button"
onClick={() => switchLocale(l.code)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm font-medium transition-all duration-100 rounded-lg mx-1 ${
l.code === locale
? "opacity-100"
: "opacity-70 hover:opacity-100"
}`}
style={{
color: "var(--color-text-readable, var(--color-text))",
background:
l.code === locale
? "color-mix(in srgb, var(--color-primary) 10%, transparent)"
: "none",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-primary) 10%, transparent)";
e.currentTarget.style.color = "var(--color-primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
l.code === locale
? "color-mix(in srgb, var(--color-primary) 10%, transparent)"
: "transparent";
e.currentTarget.style.color = "var(--color-text)";
}}
>
<Image
src={`/assets/images/icons/flags/${l.code}.png`}
alt={l.lang}
width={16}
height={16}
className="inline-block h-4 w-auto"
/>
{l.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
+80 -73
View File
@@ -3,82 +3,89 @@
import { useState } from "react";
interface MobileNavProps {
children: React.ReactNode;
/** Accessible label for the menu button. Pass translated string from server. */
menuLabel?: string;
closeLabel?: string;
children: React.ReactNode;
/** Accessible label for the menu button. Pass translated string from server. */
menuLabel?: string;
closeLabel?: string;
}
export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Close menu" }: MobileNavProps) {
const [open, setOpen] = useState(false);
export function MobileNav({
children,
menuLabel = "Open menu",
closeLabel = "Close menu",
}: MobileNavProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex md:hidden items-center justify-center w-10 h-10 rounded-lg transition-colors duration-150 ml-auto"
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text))" }}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? closeLabel : menuLabel}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{open ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
return (
<>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex md:hidden items-center justify-center w-10 h-10 rounded-lg transition-colors duration-150 ml-auto"
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
}}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? closeLabel : menuLabel}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{open ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
{open && (
<div
className="fixed inset-0 z-30 md:hidden"
onClick={() => setOpen(false)}
style={{
backgroundColor: "rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
}}
/>
)}
{open && (
<div
className="fixed inset-0 z-30 md:hidden"
onClick={() => setOpen(false)}
style={{
backgroundColor: "rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
}}
/>
)}
<div
id="mobile-menu"
role="menu"
className={`${
open
? "max-h-[80vh] opacity-100 translate-y-0"
: "max-h-0 opacity-0 translate-y-[-6px] pointer-events-none md:pointer-events-auto"
} overflow-y-auto transition-[opacity,transform] duration-200 ease-out md:!max-h-none md:!opacity-100 md:!translate-y-0 md:!overflow-visible md:flex md:items-center md:gap-x-1 w-full md:w-auto`}
style={{ backgroundColor: "var(--color-navbar)" }}
>
<div
className="flex flex-col md:flex-row md:items-center gap-y-0.5 p-2 md:p-0 rounded-xl md:rounded-none border md:border-0 shadow-lg md:shadow-none mt-1 md:mt-0"
style={{
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
onClick={() => setOpen(false)}
>
{children}
</div>
</div>
</>
);
<div
id="mobile-menu"
role="menu"
className={`${
open
? "max-h-[80vh] opacity-100 translate-y-0"
: "max-h-0 opacity-0 translate-y-[-6px] pointer-events-none md:pointer-events-auto"
} overflow-y-auto transition-[opacity,transform] duration-200 ease-out md:!max-h-none md:!opacity-100 md:!translate-y-0 md:!overflow-visible md:flex md:items-center md:gap-x-1 w-full md:w-auto`}
style={{ backgroundColor: "var(--color-navbar)" }}
>
<div
className="flex flex-col md:flex-row md:items-center gap-y-0.5 p-2 md:p-0 rounded-xl md:rounded-none border md:border-0 shadow-lg md:shadow-none mt-1 md:mt-0"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
onClick={() => setOpen(false)}
>
{children}
</div>
</div>
</>
);
}
+71 -56
View File
@@ -1,67 +1,82 @@
"use client";
import Image from "next/image";
import { useState, useRef, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
interface NavDropdownProps {
label: string;
icon?: string;
children: ReactNode;
label: string;
icon?: string;
children: ReactNode;
}
export function NavDropdown({ label, icon, children }: NavDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
if (open) {
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
}
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [open]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node))
setOpen(false);
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
if (open) {
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
}
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [open]);
return (
<div ref={ref} className="relative group md:flex">
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen(!open);
}}
className="nav-item cursor-pointer"
aria-expanded={open}
aria-haspopup="true"
>
{icon ? <Image src={icon} alt="" width={20} height={20} className="mr-1.5" unoptimized /> : null}
{label}
<svg
className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div
className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-0 md:min-w-[200px] rounded-xl z-50 py-2"
role="menu"
onClick={() => setOpen(false)}
>
{children}
</div>
)}
</div>
);
return (
<div ref={ref} className="relative group md:flex">
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen(!open);
}}
className="nav-item cursor-pointer"
aria-expanded={open}
aria-haspopup="true"
>
{icon ? (
<Image
src={icon}
alt=""
width={20}
height={20}
className="mr-1.5"
unoptimized
/>
) : null}
{label}
<svg
className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{open && (
<div
className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-0 md:min-w-[200px] rounded-xl z-50 py-2"
role="menu"
onClick={() => setOpen(false)}
>
{children}
</div>
)}
</div>
);
}
+135 -131
View File
@@ -3,142 +3,146 @@
import { useEffect, useState } from "react";
const COLORS = [
{ name: "Wit", value: "#ffffff", text: "#1e293b" },
{ name: "Zwart", value: "#0f172a", text: "#f8fafc" },
{ name: "Donker", value: "#1e293b", text: "#f8fafc" },
{ name: "Grijs", value: "#f1f5f9", text: "#1e293b" },
{ name: "Amber", value: "#f59e0b", text: "#1e293b" },
{ name: "Goud", value: "#eab308", text: "#1e293b" },
{ name: "Oranje", value: "#ea580c", text: "#ffffff" },
{ name: "Rood", value: "#ef4444", text: "#ffffff" },
{ name: "Roze", value: "#ec4899", text: "#ffffff" },
{ name: "Paars", value: "#8b5cf6", text: "#ffffff" },
{ name: "Indigo", value: "#6366f1", text: "#ffffff" },
{ name: "Blauw", value: "#3b82f6", text: "#ffffff" },
{ name: "Cyaan", value: "#06b6d4", text: "#ffffff" },
{ name: "Groen", value: "#22c55e", text: "#1e293b" },
{ name: "Olijf", value: "#84cc16", text: "#1e293b" },
{ name: "Wit", value: "#ffffff", text: "#1e293b" },
{ name: "Zwart", value: "#0f172a", text: "#f8fafc" },
{ name: "Donker", value: "#1e293b", text: "#f8fafc" },
{ name: "Grijs", value: "#f1f5f9", text: "#1e293b" },
{ name: "Amber", value: "#f59e0b", text: "#1e293b" },
{ name: "Goud", value: "#eab308", text: "#1e293b" },
{ name: "Oranje", value: "#ea580c", text: "#ffffff" },
{ name: "Rood", value: "#ef4444", text: "#ffffff" },
{ name: "Roze", value: "#ec4899", text: "#ffffff" },
{ name: "Paars", value: "#8b5cf6", text: "#ffffff" },
{ name: "Indigo", value: "#6366f1", text: "#ffffff" },
{ name: "Blauw", value: "#3b82f6", text: "#ffffff" },
{ name: "Cyaan", value: "#06b6d4", text: "#ffffff" },
{ name: "Groen", value: "#22c55e", text: "#1e293b" },
{ name: "Olijf", value: "#84cc16", text: "#1e293b" },
];
export function NavbarColorPicker() {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() => {
if (typeof window === "undefined") return 0;
const saved = localStorage.getItem("navbarColor");
if (!saved) return 0;
return Math.max(
0,
COLORS.findIndex((c) => c.value === saved),
);
});
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() => {
if (typeof window === "undefined") return 0;
const saved = localStorage.getItem("navbarColor");
if (!saved) return 0;
return Math.max(
0,
COLORS.findIndex((c) => c.value === saved),
);
});
const [mounted, setMounted] = useState(false);
function apply(index: number) {
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
const c = COLORS[index];
if (!c) return;
document.documentElement.style.setProperty("--color-navbar", c.value);
document.documentElement.style.setProperty("--color-navbar-text", c.text);
try {
localStorage.setItem("navbarColor", c.value);
localStorage.setItem("navbarTextColor", c.text);
} catch {}
setActive(index);
setOpen(false);
}
function apply(index: number) {
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
const c = COLORS[index];
if (!c) return;
document.documentElement.style.setProperty("--color-navbar", c.value);
document.documentElement.style.setProperty("--color-navbar-text", c.text);
try {
localStorage.setItem("navbarColor", c.value);
localStorage.setItem("navbarTextColor", c.text);
} catch {}
setActive(index);
setOpen(false);
}
useEffect(() => {
const saved = localStorage.getItem("navbarColor");
const savedText = localStorage.getItem("navbarTextColor");
if (saved) {
document.documentElement.style.setProperty("--color-navbar", saved);
}
if (savedText) {
document.documentElement.style.setProperty("--color-navbar-text", savedText);
}
setMounted(true);
}, []);
useEffect(() => {
const saved = localStorage.getItem("navbarColor");
const savedText = localStorage.getItem("navbarTextColor");
if (saved) {
document.documentElement.style.setProperty("--color-navbar", saved);
}
if (savedText) {
document.documentElement.style.setProperty(
"--color-navbar-text",
savedText,
);
}
setMounted(true);
}, []);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="nav-item cursor-pointer px-2"
aria-label="Navbar kleur"
title="Navbar kleur"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r="0.5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r="0.5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r="0.5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.5-4.5-10-10-10z" />
</svg>
</button>
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="nav-item cursor-pointer px-2"
aria-label="Navbar kleur"
title="Navbar kleur"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r="0.5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r="0.5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r="0.5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.5-4.5-10-10-10z" />
</svg>
</button>
{mounted && open && (
<div className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] sm:min-w-[200px] rounded-xl z-50 py-3 px-2">
<div
className="text-xs font-semibold uppercase tracking-wider mb-2 px-2"
style={{ color: "var(--color-text-muted)" }}
>
Navbar kleur
</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-1.5">
{COLORS.map((c, i) => (
<button
key={c.name}
type="button"
onClick={() => apply(i)}
className="flex flex-col items-center gap-1 p-1.5 rounded-lg transition-all duration-100"
title={c.name}
style={{
background:
i === active
? "color-mix(in srgb, var(--color-primary) 12%, transparent)"
: "transparent",
border: "none",
cursor: "pointer",
}}
onMouseEnter={(e) => {
if (i !== active)
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)";
}}
onMouseLeave={(e) => {
if (i !== active) e.currentTarget.style.backgroundColor = "transparent";
}}
>
<span
className="block w-6 h-6 rounded-full border-2 shrink-0"
style={{
backgroundColor: c.value,
borderColor:
i === active
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
}}
/>
<span
className="text-[10px] font-medium leading-tight text-center"
style={{ color: "var(--color-text-muted)" }}
>
{c.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
{mounted && open && (
<div className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] sm:min-w-[200px] rounded-xl z-50 py-3 px-2">
<div
className="text-xs font-semibold uppercase tracking-wider mb-2 px-2"
style={{ color: "var(--color-text-muted)" }}
>
Navbar kleur
</div>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-1.5">
{COLORS.map((c, i) => (
<button
key={c.name}
type="button"
onClick={() => apply(i)}
className="flex flex-col items-center gap-1 p-1.5 rounded-lg transition-all duration-100"
title={c.name}
style={{
background:
i === active
? "color-mix(in srgb, var(--color-primary) 12%, transparent)"
: "transparent",
border: "none",
cursor: "pointer",
}}
onMouseEnter={(e) => {
if (i !== active)
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)";
}}
onMouseLeave={(e) => {
if (i !== active)
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<span
className="block w-6 h-6 rounded-full border-2 shrink-0"
style={{
backgroundColor: c.value,
borderColor:
i === active
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
}}
/>
<span
className="text-[10px] font-medium leading-tight text-center"
style={{ color: "var(--color-text-muted)" }}
>
{c.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+175 -153
View File
@@ -1,174 +1,196 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import Image from "next/image";
import { auth } from "@/lib/auth";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { LanguageSwitcher } from "@/components/language-switcher";
import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { auth } from "@/lib/auth";
export async function Navigation() {
const session = await auth();
const t = await getTranslations("nav");
const isStaff = (session?.user?.rank ?? 0) >= 7;
const session = await auth();
const t = await getTranslations("nav");
const isStaff = (session?.user?.rank ?? 0) >= 7;
return (
<nav className="sticky top-0 z-40 shadow-sm" style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="relative mx-auto flex min-h-[56px] max-w-7xl items-center px-4 md:px-6 gap-x-1">
<div
className="absolute bottom-0 left-0 right-0 h-[3px]"
style={{ backgroundColor: "var(--color-primary, #eeb425)" }}
/>
return (
<nav
className="sticky top-0 z-40 shadow-sm"
style={{ backgroundColor: "var(--color-navbar)" }}
>
<div className="relative mx-auto flex min-h-[56px] max-w-7xl items-center px-4 md:px-6 gap-x-1">
<div
className="absolute bottom-0 left-0 right-0 h-[3px]"
style={{ backgroundColor: "var(--color-primary, #eeb425)" }}
/>
<div className="hidden md:flex items-center gap-x-1">
<Link href="/" className="nav-item shrink-0">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<span>{t("home")}</span>
</Link>
</div>
<div className="hidden md:flex items-center gap-x-1">
<Link href="/" className="nav-item shrink-0">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<span>{t("home")}</span>
</Link>
</div>
<MobileNav menuLabel={t("openMenu") || "Open menu"} closeLabel={t("closeMenu") || "Close menu"}>
<Link href="/" className="nav-item md:hidden" role="menuitem">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("home")}
</Link>
<MobileNav
menuLabel={t("openMenu") || "Open menu"}
closeLabel={t("closeMenu") || "Close menu"}
>
<Link href="/" className="nav-item md:hidden" role="menuitem">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("home")}
</Link>
<NavDropdown label={t("community")} icon="/assets/images/icons/navigation/community.png">
<Link href="/news" className="dropdown-item" role="menuitem">
{t("articles")}
</Link>
<Link href="/photos" className="dropdown-item" role="menuitem">
{t("photos")}
</Link>
<Link href="/staff" className="dropdown-item" role="menuitem">
{t("staff")}
</Link>
<Link href="/rankings" className="dropdown-item" role="menuitem">
{t("rankings")}
</Link>
<Link href="/guilds" className="dropdown-item" role="menuitem">
{t("guilds")}
</Link>
</NavDropdown>
<NavDropdown
label={t("community")}
icon="/assets/images/icons/navigation/community.png"
>
<Link href="/news" className="dropdown-item" role="menuitem">
{t("articles")}
</Link>
<Link href="/photos" className="dropdown-item" role="menuitem">
{t("photos")}
</Link>
<Link href="/staff" className="dropdown-item" role="menuitem">
{t("staff")}
</Link>
<Link href="/rankings" className="dropdown-item" role="menuitem">
{t("rankings")}
</Link>
<Link href="/guilds" className="dropdown-item" role="menuitem">
{t("guilds")}
</Link>
</NavDropdown>
<NavDropdown label={t("shop")} icon="/assets/images/icons/navigation/shop.png">
<Link href="/shop" className="dropdown-item" role="menuitem">
{t("shop")}
</Link>
<Link href="/marketplace" className="dropdown-item" role="menuitem">
{t("market")}
</Link>
<Link href="/rares" className="dropdown-item" role="menuitem">
{t("rareValues")}
</Link>
</NavDropdown>
<NavDropdown
label={t("shop")}
icon="/assets/images/icons/navigation/shop.png"
>
<Link href="/shop" className="dropdown-item" role="menuitem">
{t("shop")}
</Link>
<Link href="/marketplace" className="dropdown-item" role="menuitem">
{t("market")}
</Link>
<Link href="/rares" className="dropdown-item" role="menuitem">
{t("rareValues")}
</Link>
</NavDropdown>
<Link href="/leaderboard" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/leaderboards.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("leaderboards")}
</Link>
<Link href="/leaderboard" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/leaderboards.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("leaderboards")}
</Link>
<Link href="/radio" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("radio")}
</Link>
<Link href="/radio" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("radio")}
</Link>
<NavDropdown label={t("assistance")} icon="/assets/images/icons/navigation/rules.gif">
<Link href="/help" className="dropdown-item" role="menuitem">
{t("helpCenter")}
</Link>
{session?.user ? (
<Link href="/help/tickets" className="dropdown-item" role="menuitem">
{t("myTickets")}
</Link>
) : null}
<Link href="/badges" className="dropdown-item" role="menuitem">
{t("badges")}
</Link>
<Link href="/draw-badge" className="dropdown-item" role="menuitem">
{t("drawBadge")}
</Link>
</NavDropdown>
<NavDropdown
label={t("assistance")}
icon="/assets/images/icons/navigation/rules.gif"
>
<Link href="/help" className="dropdown-item" role="menuitem">
{t("helpCenter")}
</Link>
{session?.user ? (
<Link
href="/help/tickets"
className="dropdown-item"
role="menuitem"
>
{t("myTickets")}
</Link>
) : null}
<Link href="/badges" className="dropdown-item" role="menuitem">
{t("badges")}
</Link>
<Link href="/draw-badge" className="dropdown-item" role="menuitem">
{t("drawBadge")}
</Link>
</NavDropdown>
{session?.user ? (
<>
<Link href="/friends" className="nav-item">
<Image
src="/assets/images/icons/navigation/goody.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("friends")}
</Link>
<Link href="/messages" className="nav-item">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("messages")}
</Link>
</>
) : null}
{session?.user ? (
<>
<Link href="/friends" className="nav-item">
<Image
src="/assets/images/icons/navigation/goody.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("friends")}
</Link>
<Link href="/messages" className="nav-item">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("messages")}
</Link>
</>
) : null}
{isStaff ? (
<Link href="/admin" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("admin")}
</Link>
) : null}
{isStaff ? (
<Link href="/admin" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
{t("admin")}
</Link>
) : null}
<div
className="flex items-center gap-x-1 pt-2 mt-2 border-t md:hidden"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}
>
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
</div>
</MobileNav>
<div
className="flex items-center gap-x-1 pt-2 mt-2 border-t md:hidden"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
</div>
</MobileNav>
<div className="ml-auto flex items-center gap-x-1 hidden md:flex">
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
</div>
</div>
</nav>
);
<div className="ml-auto flex items-center gap-x-1 hidden md:flex">
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
</div>
</div>
</nav>
);
}
+206 -206
View File
@@ -1,219 +1,219 @@
export interface HabboFont {
name: string;
styleName: string;
hasNumbers?: boolean;
name: string;
styleName: string;
hasNumbers?: boolean;
}
export interface HabboFontGroup {
groupName: string;
fonts: HabboFont[];
groupName: string;
fonts: HabboFont[];
}
export const HABBO_FONT_GROUPS: HabboFontGroup[] = [
{
groupName: "Habbo (related) logo",
fonts: [
{ name: "Habbo new", styleName: "habbo_new", hasNumbers: true },
{ name: "Habbo old", styleName: "habbo_big", hasNumbers: true },
{ name: "Habbo new big", styleName: "habbo_new_big", hasNumbers: true },
{ name: "Habbo old big", styleName: "habbo_old_big", hasNumbers: true },
{ name: "Habbo", styleName: "habbo", hasNumbers: true },
{ name: "Habbo Tiny", styleName: "habbo_tiny" },
{ name: "Habbo oldschool", styleName: "habbo_oldschool" },
{ name: "Habbo blocky", styleName: "habbo_blocky" },
{ name: "Habjoy", styleName: "habjoy" },
{ name: "Habjoy light", styleName: "habjoy_light" },
{ name: "Paradise", styleName: "paradise" },
{ name: "Paradise 2", styleName: "paradise_rounded" },
{ name: "Habbo Clicker", styleName: "habbo_clicker" },
{ name: "Reception", styleName: "reception" },
],
},
{
groupName: "Generic fonts",
fonts: [
{ name: "Accessoires", styleName: "accessoires" },
{ name: "Africa", styleName: "africa" },
{ name: "Arctic", styleName: "arctic", hasNumbers: true },
{ name: "Asian", styleName: "asian" },
{ name: "Asian small", styleName: "asian_small" },
{ name: "Basic", styleName: "basic", hasNumbers: true },
{ name: "Base", styleName: "base", hasNumbers: true },
{ name: "Bathroom", styleName: "bathroom", hasNumbers: true },
{ name: "Battle Banzai", styleName: "battlebanzai" },
{ name: "Bensalem", styleName: "bensalem", hasNumbers: true },
{ name: "Bling", styleName: "bling", hasNumbers: true },
{ name: "Bling Gold", styleName: "bling_gold" },
{ name: "Bones", styleName: "bones" },
{ name: "Bots", styleName: "bots" },
{ name: "Bubble", styleName: "bubble" },
{ name: "Bubble blue", styleName: "bubble_blue" },
{ name: "Bubble orange", styleName: "bubble_orange" },
{ name: "Bunny run", styleName: "bunny_run" },
{ name: "Bunny run blue", styleName: "bunny_run_blue" },
{ name: "Buttons", styleName: "buttons" },
{ name: "Candy", styleName: "candy" },
{ name: "Collectibles", styleName: "collectibles" },
{ name: "Cubie", styleName: "cubie" },
{ name: "Disco", styleName: "disco" },
{ name: "Dreams flat", styleName: "dreams_flat" },
{ name: "Duckets", styleName: "duckets" },
{ name: "Emo", styleName: "emo" },
{ name: "Executive", styleName: "executive" },
{ name: "Explore", styleName: "explore" },
{ name: "Explore big", styleName: "explore_big" },
{ name: "Explore small", styleName: "explore_small" },
{ name: "F.A.Q.", styleName: "faq" },
{ name: "F.BOMB", styleName: "fbomb" },
{ name: "Festival", styleName: "festival" },
{ name: "Flags", styleName: "flags" },
{ name: "Freehand", styleName: "freehand" },
{ name: "Gaming", styleName: "gaming" },
{ name: "Gallery", styleName: "gallery" },
{ name: "Golden temple", styleName: "goldentemple" },
{
groupName: "Habbo (related) logo",
fonts: [
{ name: "Habbo new", styleName: "habbo_new", hasNumbers: true },
{ name: "Habbo old", styleName: "habbo_big", hasNumbers: true },
{ name: "Habbo new big", styleName: "habbo_new_big", hasNumbers: true },
{ name: "Habbo old big", styleName: "habbo_old_big", hasNumbers: true },
{ name: "Habbo", styleName: "habbo", hasNumbers: true },
{ name: "Habbo Tiny", styleName: "habbo_tiny" },
{ name: "Habbo oldschool", styleName: "habbo_oldschool" },
{ name: "Habbo blocky", styleName: "habbo_blocky" },
{ name: "Habjoy", styleName: "habjoy" },
{ name: "Habjoy light", styleName: "habjoy_light" },
{ name: "Paradise", styleName: "paradise" },
{ name: "Paradise 2", styleName: "paradise_rounded" },
{ name: "Habbo Clicker", styleName: "habbo_clicker" },
{ name: "Reception", styleName: "reception" },
],
},
{
groupName: "Generic fonts",
fonts: [
{ name: "Accessoires", styleName: "accessoires" },
{ name: "Africa", styleName: "africa" },
{ name: "Arctic", styleName: "arctic", hasNumbers: true },
{ name: "Asian", styleName: "asian" },
{ name: "Asian small", styleName: "asian_small" },
{ name: "Basic", styleName: "basic", hasNumbers: true },
{ name: "Base", styleName: "base", hasNumbers: true },
{ name: "Bathroom", styleName: "bathroom", hasNumbers: true },
{ name: "Battle Banzai", styleName: "battlebanzai" },
{ name: "Bensalem", styleName: "bensalem", hasNumbers: true },
{ name: "Bling", styleName: "bling", hasNumbers: true },
{ name: "Bling Gold", styleName: "bling_gold" },
{ name: "Bones", styleName: "bones" },
{ name: "Bots", styleName: "bots" },
{ name: "Bubble", styleName: "bubble" },
{ name: "Bubble blue", styleName: "bubble_blue" },
{ name: "Bubble orange", styleName: "bubble_orange" },
{ name: "Bunny run", styleName: "bunny_run" },
{ name: "Bunny run blue", styleName: "bunny_run_blue" },
{ name: "Buttons", styleName: "buttons" },
{ name: "Candy", styleName: "candy" },
{ name: "Collectibles", styleName: "collectibles" },
{ name: "Cubie", styleName: "cubie" },
{ name: "Disco", styleName: "disco" },
{ name: "Dreams flat", styleName: "dreams_flat" },
{ name: "Duckets", styleName: "duckets" },
{ name: "Emo", styleName: "emo" },
{ name: "Executive", styleName: "executive" },
{ name: "Explore", styleName: "explore" },
{ name: "Explore big", styleName: "explore_big" },
{ name: "Explore small", styleName: "explore_small" },
{ name: "F.A.Q.", styleName: "faq" },
{ name: "F.BOMB", styleName: "fbomb" },
{ name: "Festival", styleName: "festival" },
{ name: "Flags", styleName: "flags" },
{ name: "Freehand", styleName: "freehand" },
{ name: "Gaming", styleName: "gaming" },
{ name: "Gallery", styleName: "gallery" },
{ name: "Golden temple", styleName: "goldentemple" },
{ name: "HMA", styleName: "hma" },
{ name: "Habbo gold", styleName: "gold_big" },
{ name: "Habbo news", styleName: "habbo_news" },
{ name: "Habbo Runway", styleName: "habbo_runway" },
{ name: "Habbid red", styleName: "habbid_red" },
{ name: "Habbid blue", styleName: "habbid_blue" },
{ name: "Habbolife", styleName: "habbolife" },
{ name: "Habbowood", styleName: "habbowood" },
{ name: "Habbowood big", styleName: "habbowood_big" },
{ name: "Hands", styleName: "hands" },
{ name: "Hello", styleName: "hello" },
{ name: "Horse", styleName: "horse" },
{ name: "Ice", styleName: "ice" },
{ name: "Iced", styleName: "iced" },
{ name: "Iron", styleName: "iron" },
{ name: "Japan", styleName: "japan" },
{ name: "Love", styleName: "love" },
{ name: "Marketplace red", styleName: "marketplace_red" },
{ name: "Monkey", styleName: "monkey" },
{ name: "Neon blue", styleName: "neon_blue", hasNumbers: true },
{ name: "Neon green", styleName: "neon_green", hasNumbers: true },
{ name: "Neon pink", styleName: "neon_pink", hasNumbers: true },
{ name: "Occasional", styleName: "occasional" },
{ name: "Palooza", styleName: "palooza" },
{ name: "Palooza blue", styleName: "palooza_blue" },
{ name: "Pets", styleName: "pets" },
{ name: "Pillows Orange", styleName: "pillow_orange" },
{ name: "Pillows Pink", styleName: "pillow_pink" },
{ name: "Pipes", styleName: "pipes" },
{ name: "Plants", styleName: "plants" },
{ name: "Plastic", styleName: "plastic" },
{ name: "Recycle", styleName: "recycle" },
{ name: "Retropixel", styleName: "retropixel" },
{ name: "Rugs", styleName: "rugs" },
{ name: "Sale", styleName: "sale" },
{ name: "Shalimar", styleName: "shalimar", hasNumbers: true },
{ name: "Shalimar big", styleName: "shalimar_big", hasNumbers: true },
{ name: "Shalimar small", styleName: "shalimar_small", hasNumbers: true },
{ name: "Soccer", styleName: "soccer" },
{ name: "Soccer red", styleName: "soccer_red" },
{ name: "Soccer yellow", styleName: "soccer_yellow" },
{ name: "Space Cafe", styleName: "space" },
{ name: "Spaces", styleName: "spaces" },
{ name: "Special offers", styleName: "special_offers" },
{ name: "StrayPixels", styleName: "straypixels" },
{ name: "Summer", styleName: "summer" },
{ name: "Teleport", styleName: "teleport", hasNumbers: true },
{ name: "Theater lights", styleName: "theater_lights" },
{ name: "Tribe", styleName: "tribe" },
{ name: "Trophies", styleName: "trophies" },
{ name: "USVA", styleName: "usva" },
{ name: "Unknown blue", styleName: "unknown_blue" },
{ name: "Unknown metal", styleName: "unknown_metal" },
{ name: "Unknown white", styleName: "unknown_white" },
{ name: "Unknown pillow", styleName: "unknown_pillow_1" },
{ name: "Unknown pillow", styleName: "unknown_pillow_2" },
{ name: "Valentine", styleName: "valentine" },
{ name: "Western", styleName: "western" },
{ name: "Woods", styleName: "wood" },
{ name: "Yellow text", styleName: "yellow_text" },
],
},
{
groupName: "Custom creations",
fonts: [{ name: "Jurrasic", styleName: "jurrasic" }],
},
{
groupName: "Halloween fonts",
fonts: [
{ name: "Halloween", styleName: "halloween" },
{ name: "Halloween 2", styleName: "halloween_2" },
{ name: "Halloween 3", styleName: "halloween_3" },
],
},
{
groupName: "Group fonts",
fonts: [
{ name: "Plastic playful", styleName: "plastic_playful" },
{ name: "Dinner blue", styleName: "dinner_blue", hasNumbers: true },
{ name: "Dinner green", styleName: "dinner_green", hasNumbers: true },
{ name: "Dinner red", styleName: "dinner_red", hasNumbers: true },
],
},
{
groupName: "Games",
fonts: [
{ name: "Battleball", styleName: "battleball" },
{ name: "Lido", styleName: "lido", hasNumbers: true },
],
},
{
groupName: "Habbo club",
fonts: [
{ name: "Habbo Club", styleName: "hc" },
{ name: "HC Big", styleName: "hc_big" },
{ name: "HC Small", styleName: "hc_small" },
{ name: "HC Compact", styleName: "hc_compact", hasNumbers: true },
{ name: "HC gold", styleName: "hc_gold" },
{ name: "Habbo Club 2", styleName: "habboclub", hasNumbers: true },
{ name: "VIP", styleName: "habbovip", hasNumbers: true },
],
},
{
groupName: "Uncategorised",
fonts: [
{ name: "Agenda", styleName: "agenda", hasNumbers: true },
{ name: "Mini white", styleName: "mini_white" },
{ name: "Volter goldfish", styleName: "volter" },
{ name: "Volter shadow", styleName: "volter_shadow" },
{ name: "Lucky goldfish", styleName: "volter_modern" },
{ name: "Goldfish small", styleName: "volter_modern_small" },
{ name: "Black", styleName: "black" },
],
},
{
groupName: "Windows",
fonts: [
{ name: "Windows", styleName: "windows" },
{ name: "HMA", styleName: "hma" },
{ name: "Habbo gold", styleName: "gold_big" },
{ name: "Habbo news", styleName: "habbo_news" },
{ name: "Habbo Runway", styleName: "habbo_runway" },
{ name: "Habbid red", styleName: "habbid_red" },
{ name: "Habbid blue", styleName: "habbid_blue" },
{ name: "Habbolife", styleName: "habbolife" },
{ name: "Habbowood", styleName: "habbowood" },
{ name: "Habbowood big", styleName: "habbowood_big" },
{ name: "Hands", styleName: "hands" },
{ name: "Hello", styleName: "hello" },
{ name: "Horse", styleName: "horse" },
{ name: "Ice", styleName: "ice" },
{ name: "Iced", styleName: "iced" },
{ name: "Iron", styleName: "iron" },
{ name: "Japan", styleName: "japan" },
{ name: "Love", styleName: "love" },
{ name: "Marketplace red", styleName: "marketplace_red" },
{ name: "Monkey", styleName: "monkey" },
{ name: "Neon blue", styleName: "neon_blue", hasNumbers: true },
{ name: "Neon green", styleName: "neon_green", hasNumbers: true },
{ name: "Neon pink", styleName: "neon_pink", hasNumbers: true },
{ name: "Occasional", styleName: "occasional" },
{ name: "Palooza", styleName: "palooza" },
{ name: "Palooza blue", styleName: "palooza_blue" },
{ name: "Pets", styleName: "pets" },
{ name: "Pillows Orange", styleName: "pillow_orange" },
{ name: "Pillows Pink", styleName: "pillow_pink" },
{ name: "Pipes", styleName: "pipes" },
{ name: "Plants", styleName: "plants" },
{ name: "Plastic", styleName: "plastic" },
{ name: "Recycle", styleName: "recycle" },
{ name: "Retropixel", styleName: "retropixel" },
{ name: "Rugs", styleName: "rugs" },
{ name: "Sale", styleName: "sale" },
{ name: "Shalimar", styleName: "shalimar", hasNumbers: true },
{ name: "Shalimar big", styleName: "shalimar_big", hasNumbers: true },
{ name: "Shalimar small", styleName: "shalimar_small", hasNumbers: true },
{ name: "Soccer", styleName: "soccer" },
{ name: "Soccer red", styleName: "soccer_red" },
{ name: "Soccer yellow", styleName: "soccer_yellow" },
{ name: "Space Cafe", styleName: "space" },
{ name: "Spaces", styleName: "spaces" },
{ name: "Special offers", styleName: "special_offers" },
{ name: "StrayPixels", styleName: "straypixels" },
{ name: "Summer", styleName: "summer" },
{ name: "Teleport", styleName: "teleport", hasNumbers: true },
{ name: "Theater lights", styleName: "theater_lights" },
{ name: "Tribe", styleName: "tribe" },
{ name: "Trophies", styleName: "trophies" },
{ name: "USVA", styleName: "usva" },
{ name: "Unknown blue", styleName: "unknown_blue" },
{ name: "Unknown metal", styleName: "unknown_metal" },
{ name: "Unknown white", styleName: "unknown_white" },
{ name: "Unknown pillow", styleName: "unknown_pillow_1" },
{ name: "Unknown pillow", styleName: "unknown_pillow_2" },
{ name: "Valentine", styleName: "valentine" },
{ name: "Western", styleName: "western" },
{ name: "Woods", styleName: "wood" },
{ name: "Yellow text", styleName: "yellow_text" },
],
},
{
groupName: "Custom creations",
fonts: [{ name: "Jurrasic", styleName: "jurrasic" }],
},
{
groupName: "Halloween fonts",
fonts: [
{ name: "Halloween", styleName: "halloween" },
{ name: "Halloween 2", styleName: "halloween_2" },
{ name: "Halloween 3", styleName: "halloween_3" },
],
},
{
groupName: "Group fonts",
fonts: [
{ name: "Plastic playful", styleName: "plastic_playful" },
{ name: "Dinner blue", styleName: "dinner_blue", hasNumbers: true },
{ name: "Dinner green", styleName: "dinner_green", hasNumbers: true },
{ name: "Dinner red", styleName: "dinner_red", hasNumbers: true },
],
},
{
groupName: "Games",
fonts: [
{ name: "Battleball", styleName: "battleball" },
{ name: "Lido", styleName: "lido", hasNumbers: true },
],
},
{
groupName: "Habbo club",
fonts: [
{ name: "Habbo Club", styleName: "hc" },
{ name: "HC Big", styleName: "hc_big" },
{ name: "HC Small", styleName: "hc_small" },
{ name: "HC Compact", styleName: "hc_compact", hasNumbers: true },
{ name: "HC gold", styleName: "hc_gold" },
{ name: "Habbo Club 2", styleName: "habboclub", hasNumbers: true },
{ name: "VIP", styleName: "habbovip", hasNumbers: true },
],
},
{
groupName: "Uncategorised",
fonts: [
{ name: "Agenda", styleName: "agenda", hasNumbers: true },
{ name: "Mini white", styleName: "mini_white" },
{ name: "Volter goldfish", styleName: "volter" },
{ name: "Volter shadow", styleName: "volter_shadow" },
{ name: "Lucky goldfish", styleName: "volter_modern" },
{ name: "Goldfish small", styleName: "volter_modern_small" },
{ name: "Black", styleName: "black" },
],
},
{
groupName: "Windows",
fonts: [
{ name: "Windows", styleName: "windows" },
{ name: "Blue", styleName: "windows_blue", hasNumbers: true },
{ name: "Green", styleName: "windows_green", hasNumbers: true },
{ name: "Pink", styleName: "windows_pink", hasNumbers: true },
{ name: "Red", styleName: "windows_red", hasNumbers: true },
{ name: "Dark red", styleName: "windows_red_dark", hasNumbers: true },
],
},
{
groupName: "Beta",
fonts: [
{ name: "Beta big", styleName: "beta_big" },
{ name: "Beta small", styleName: "beta_small" },
{ name: "Beta old", styleName: "beta_small_old" },
{ name: "Beta", styleName: "beta", hasNumbers: true },
],
},
{
groupName: "Special fonts",
fonts: [{ name: "Relax", styleName: "relax" }],
},
{ name: "Blue", styleName: "windows_blue", hasNumbers: true },
{ name: "Green", styleName: "windows_green", hasNumbers: true },
{ name: "Pink", styleName: "windows_pink", hasNumbers: true },
{ name: "Red", styleName: "windows_red", hasNumbers: true },
{ name: "Dark red", styleName: "windows_red_dark", hasNumbers: true },
],
},
{
groupName: "Beta",
fonts: [
{ name: "Beta big", styleName: "beta_big" },
{ name: "Beta small", styleName: "beta_small" },
{ name: "Beta old", styleName: "beta_small_old" },
{ name: "Beta", styleName: "beta", hasNumbers: true },
],
},
{
groupName: "Special fonts",
fonts: [{ name: "Relax", styleName: "relax" }],
},
];
export function buildFontUrl(styleName: string, text: string): string {
const t = text.toLowerCase().replace(/\s/g, "+");
return `/api/font/${styleName}/${t}`;
const t = text.toLowerCase().replace(/\s/g, "+");
return `/api/font/${styleName}/${t}`;
}
+296 -225
View File
@@ -1,249 +1,320 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { ContentCard } from "@/components/public/ui";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useTransition,
} from "react";
import { saveLogo } from "@/actions/save-logo";
import { HABBO_FONT_GROUPS } from "@/components/public/habbo-fonts";
import { getFontInfo, getMissingChars, renderToCanvas } from "@/components/public/sprite-font";
import {
getFontInfo,
getMissingChars,
renderToCanvas,
} from "@/components/public/sprite-font";
import { ContentCard } from "@/components/public/ui";
export default function LogoGenerator() {
const [text, setText] = useState("Atom");
const [styleName, setStyleName] = useState("habbo");
const [search, setSearch] = useState("");
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "done" | "error">("idle");
const [fontLoaded, setFontLoaded] = useState(false);
const [fontError, setFontError] = useState(false);
const [missingChars, setMissingChars] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [text, setText] = useState("Atom");
const [styleName, setStyleName] = useState("habbo");
const [search, setSearch] = useState("");
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "done" | "error"
>("idle");
const [fontLoaded, setFontLoaded] = useState(false);
const [fontError, setFontError] = useState(false);
const [missingChars, setMissingChars] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition();
const canvasRef = useRef<HTMLCanvasElement>(null);
const safeText = text.trim() || "Your hotel";
const safeText = text.trim() || "Your hotel";
const currentFont = useMemo(
() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts).find((f) => f.styleName === styleName),
[styleName],
);
const currentFont = useMemo(
() =>
HABBO_FONT_GROUPS.flatMap((g) => g.fonts).find(
(f) => f.styleName === styleName,
),
[styleName],
);
const filteredGroups = useMemo(
() =>
search
? HABBO_FONT_GROUPS.map((g) => ({
...g,
fonts: g.fonts.filter(
(f) =>
f.name.toLowerCase().includes(search.toLowerCase()) ||
f.styleName.toLowerCase().includes(search.toLowerCase()),
),
})).filter((g) => g.fonts.length > 0)
: HABBO_FONT_GROUPS,
[search],
);
const filteredGroups = useMemo(
() =>
search
? HABBO_FONT_GROUPS.map((g) => ({
...g,
fonts: g.fonts.filter(
(f) =>
f.name.toLowerCase().includes(search.toLowerCase()) ||
f.styleName.toLowerCase().includes(search.toLowerCase()),
),
})).filter((g) => g.fonts.length > 0)
: HABBO_FONT_GROUPS,
[search],
);
// Load font and render preview
useEffect(() => {
let cancelled = false;
// Load font and render preview
useEffect(() => {
let cancelled = false;
getFontInfo(styleName)
.then((info) => {
if (cancelled) return;
const canvas = renderToCanvas(info.chars, info.h, info.top, info.sheet, safeText, info.avgW);
if (canvas && canvasRef.current) {
canvasRef.current.width = canvas.width;
canvasRef.current.height = canvas.height;
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(canvas, 0, 0);
}
setMissingChars(getMissingChars(info.chars, safeText));
setFontLoaded(true);
})
.catch(() => {
if (!cancelled) setFontError(true);
});
getFontInfo(styleName)
.then((info) => {
if (cancelled) return;
const canvas = renderToCanvas(
info.chars,
info.h,
info.top,
info.sheet,
safeText,
info.avgW,
);
if (canvas && canvasRef.current) {
canvasRef.current.width = canvas.width;
canvasRef.current.height = canvas.height;
const ctx = canvasRef.current.getContext("2d");
ctx?.drawImage(canvas, 0, 0);
}
setMissingChars(getMissingChars(info.chars, safeText));
setFontLoaded(true);
})
.catch(() => {
if (!cancelled) setFontError(true);
});
return () => {
cancelled = true;
};
}, [styleName, safeText]);
return () => {
cancelled = true;
};
}, [styleName, safeText]);
const handleDownload = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}.png`;
a.click();
URL.revokeObjectURL(a.href);
}, "image/png");
}, [safeText]);
const handleDownload = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}.png`;
a.click();
URL.revokeObjectURL(a.href);
}, "image/png");
}, [safeText]);
const handleSave = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
setSaveStatus("saving");
canvas.toBlob((blob) => {
if (!blob) {
setSaveStatus("error");
return;
}
const file = new File([blob], "logo.png", { type: "image/png" });
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const result = await saveLogo(fd);
setSaveStatus(result.success ? "done" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
}, "image/png");
}, [startTransition]);
const handleSave = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
setSaveStatus("saving");
canvas.toBlob((blob) => {
if (!blob) {
setSaveStatus("error");
return;
}
const file = new File([blob], "logo.png", { type: "image/png" });
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const result = await saveLogo(fd);
setSaveStatus(result.success ? "done" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
}, "image/png");
}, []);
const [zipping, setZipping] = useState(false);
const allFonts = useMemo(() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts), []);
const [zipping, setZipping] = useState(false);
const allFonts = useMemo(() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts), []);
const handleDownloadAll = useCallback(async () => {
if (zipping) return;
setZipping(true);
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const folder = zip.folder(safeText.replace(/[^a-z0-9]+/gi, "_") || "logo");
if (!folder) throw new Error("Failed to create zip folder");
let fail = 0;
const handleDownloadAll = useCallback(async () => {
if (zipping) return;
setZipping(true);
try {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
const folder = zip.folder(
safeText.replace(/[^a-z0-9]+/gi, "_") || "logo",
);
if (!folder) throw new Error("Failed to create zip folder");
let fail = 0;
for (const font of allFonts) {
try {
const info = await getFontInfo(font.styleName);
const canvas = renderToCanvas(info.chars, info.h, info.top, info.sheet, safeText, info.avgW);
if (!canvas) {
fail++;
continue;
}
const blob = await new Promise<Blob | null>((r) => canvas.toBlob((b) => r(b), "image/png"));
if (blob) folder.file(`${font.styleName}.png`, blob);
else fail++;
} catch {
fail++;
}
}
for (const font of allFonts) {
try {
const info = await getFontInfo(font.styleName);
const canvas = renderToCanvas(
info.chars,
info.h,
info.top,
info.sheet,
safeText,
info.avgW,
);
if (!canvas) {
fail++;
continue;
}
const blob = await new Promise<Blob | null>((r) =>
canvas.toBlob((b) => r(b), "image/png"),
);
if (blob) folder.file(`${font.styleName}.png`, blob);
else fail++;
} catch {
fail++;
}
}
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}-all-fonts.zip`;
a.click();
URL.revokeObjectURL(a.href);
if (fail > 0) alert(`${allFonts.length - fail}/${allFonts.length} fonts (${fail} failed)`);
} catch {
alert("Failed to create ZIP.");
} finally {
setZipping(false);
}
}, [safeText, allFonts, zipping]);
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}-all-fonts.zip`;
a.click();
URL.revokeObjectURL(a.href);
if (fail > 0)
alert(
`${allFonts.length - fail}/${allFonts.length} fonts (${fail} failed)`,
);
} catch {
alert("Failed to create ZIP.");
} finally {
setZipping(false);
}
}, [safeText, allFonts, zipping]);
return (
<div className="card-grid md-2" style={{ alignItems: "start" }}>
<ContentCard icon="⚙️" title="Design" subtitle="Pick a Habbo font and type your text.">
<div style={{ display: "grid", gap: "1rem" }}>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>Hotel name</span>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Your hotel"
maxLength={40}
/>
</label>
return (
<div className="card-grid md-2" style={{ alignItems: "start" }}>
<ContentCard
icon="⚙️"
title="Design"
subtitle="Pick a Habbo font and type your text."
>
<div style={{ display: "grid", gap: "1rem" }}>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
Hotel name
</span>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Your hotel"
maxLength={40}
/>
</label>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>Search font</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to filter fonts…"
/>
</label>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
Search font
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to filter fonts…"
/>
</label>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
Font —{" "}
<span className="muted">
{currentFont?.name ?? styleName} ({styleName})
</span>
</span>
<select
value={styleName}
onChange={(e) => setStyleName(e.target.value)}
style={{ maxHeight: 320 }}
size={Math.min(filteredGroups.flatMap((g) => g.fonts).length + filteredGroups.length, 12)}
>
{filteredGroups.map((group) => (
<optgroup key={group.groupName} label={group.groupName}>
{group.fonts.map((f) => (
<option key={f.styleName} value={f.styleName}>
{f.name}
</option>
))}
</optgroup>
))}
</select>
</label>
<label style={{ display: "grid", gap: "0.3rem" }}>
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
Font —{" "}
<span className="muted">
{currentFont?.name ?? styleName} ({styleName})
</span>
</span>
<select
value={styleName}
onChange={(e) => setStyleName(e.target.value)}
style={{ maxHeight: 320 }}
size={Math.min(
filteredGroups.flatMap((g) => g.fonts).length +
filteredGroups.length,
12,
)}
>
{filteredGroups.map((group) => (
<optgroup key={group.groupName} label={group.groupName}>
{group.fonts.map((f) => (
<option key={f.styleName} value={f.styleName}>
{f.name}
</option>
))}
</optgroup>
))}
</select>
</label>
<button type="button" className="btn btn-primary" onClick={handleDownload} disabled={!fontLoaded}>
⬇️ Download PNG
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={saveStatus === "saving" || !fontLoaded}
>
{saveStatus === "saving"
? "⏳ Saving..."
: saveStatus === "done"
? "✅ Saved!"
: saveStatus === "error"
? "❌ Error"
: "💾 Save as site logo"}
</button>
<button type="button" className="btn btn-secondary" onClick={handleDownloadAll} disabled={zipping}>
{zipping ? "⏳ Creating ZIP..." : "📦 Download all fonts"}
</button>
</div>
</ContentCard>
<button
type="button"
className="btn btn-primary"
onClick={handleDownload}
disabled={!fontLoaded}
>
⬇️ Download PNG
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={saveStatus === "saving" || !fontLoaded}
>
{saveStatus === "saving"
? "⏳ Saving..."
: saveStatus === "done"
? "✅ Saved!"
: saveStatus === "error"
? "❌ Error"
: "💾 Save as site logo"}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleDownloadAll}
disabled={zipping}
>
{zipping ? "⏳ Creating ZIP..." : "📦 Download all fonts"}
</button>
</div>
</ContentCard>
<ContentCard icon="👁️" title="Preview" subtitle="Rendered locally from sprite sheets.">
<div
style={{
display: "grid",
placeItems: "center",
minHeight: 220,
padding: "1.25rem",
borderRadius: "12px",
border: "1px dashed color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
overflow: "auto",
background:
"repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
}}
>
{fontError ? (
<p className="muted">Failed to load font sprite.</p>
) : fontLoaded ? (
<>
<canvas ref={canvasRef} style={{ maxWidth: "100%", height: "auto" }} />
{missingChars.size > 0 && (
<p className="muted" style={{ marginTop: 8, fontSize: "0.8rem" }}>
Note: this font does not have characters: {[...missingChars].join(", ")}
</p>
)}
</>
) : (
<p className="muted">Loading font…</p>
)}
</div>
</ContentCard>
</div>
);
<ContentCard
icon="👁️"
title="Preview"
subtitle="Rendered locally from sprite sheets."
>
<div
style={{
display: "grid",
placeItems: "center",
minHeight: 220,
padding: "1.25rem",
borderRadius: "12px",
border:
"1px dashed color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
overflow: "auto",
background:
"repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
}}
>
{fontError ? (
<p className="muted">Failed to load font sprite.</p>
) : fontLoaded ? (
<>
<canvas
ref={canvasRef}
style={{ maxWidth: "100%", height: "auto" }}
/>
{missingChars.size > 0 && (
<p
className="muted"
style={{ marginTop: 8, fontSize: "0.8rem" }}
>
Note: this font does not have characters:{" "}
{[...missingChars].join(", ")}
</p>
)}
</>
) : (
<p className="muted">Loading font…</p>
)}
</div>
</ContentCard>
</div>
);
}
+146 -142
View File
@@ -12,153 +12,157 @@ import { useCallback, useEffect, useState } from "react";
* this component carries no prisma/i18n dependency.
*/
export type LightboxPhoto = {
id: string;
url: string;
alt: string;
date: string;
user: string;
id: string;
url: string;
alt: string;
date: string;
user: string;
};
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
const active = openIndex !== null ? photos[openIndex] : null;
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
const close = useCallback(() => setOpenIndex(null), []);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
if (active == null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close]);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
if (active == null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close]);
return (
<>
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
border: 0,
background: "var(--color-surface)",
cursor: "zoom-in",
textAlign: "left",
font: "inherit",
color: "inherit",
display: "block",
width: "100%",
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.url} alt={p.alt} style={{ width: "100%", height: "auto", display: "block" }} />
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.user}
</p>
</div>
</button>
))}
</div>
return (
<>
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
border: 0,
background: "var(--color-surface)",
cursor: "zoom-in",
textAlign: "left",
font: "inherit",
color: "inherit",
display: "block",
width: "100%",
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={p.alt}
style={{ width: "100%", height: "auto", display: "block" }}
/>
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.user}
</p>
</div>
</button>
))}
</div>
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
}
+16 -16
View File
@@ -12,22 +12,22 @@ import RadioPlayer from "./radio-player";
* /api/radio/config, which lets staff toggle the radio without a redeploy.
*/
export default async function RadioPlayerGate() {
let enabled: boolean;
let streamUrl: string;
try {
const [enabledRaw, urlRaw] = await Promise.all([
siteSettings.get("radio_enabled", "0"),
siteSettings.get("radio_stream_url", ""),
]);
const flag = (enabledRaw ?? "0").trim().toLowerCase();
enabled = flag === "1" || flag === "true";
streamUrl = (urlRaw ?? "").trim();
} catch {
// Settings unavailable — fail closed (no widget).
return null;
}
let enabled: boolean;
let streamUrl: string;
try {
const [enabledRaw, urlRaw] = await Promise.all([
siteSettings.get("radio_enabled", "0"),
siteSettings.get("radio_stream_url", ""),
]);
const flag = (enabledRaw ?? "0").trim().toLowerCase();
enabled = flag === "1" || flag === "true";
streamUrl = (urlRaw ?? "").trim();
} catch {
// Settings unavailable — fail closed (no widget).
return null;
}
if (!enabled || !streamUrl) return null;
if (!enabled || !streamUrl) return null;
return <RadioPlayer />;
return <RadioPlayer />;
}
+312 -271
View File
@@ -24,36 +24,36 @@ import { useCallback, useEffect, useRef, useState } from "react";
const POLL_MS = 15000;
type RadioConfig = {
enabled: boolean;
streamUrl: string;
name: string;
enabled: boolean;
streamUrl: string;
name: string;
};
// ── Defensive parsing of the loosely-typed provider passthroughs ──────────────
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function asString(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function isTruthyFlag(v: unknown): boolean {
const s = asString(v).trim().toLowerCase();
return s === "1" || s === "true" || s === "yes" || s === "on";
const s = asString(v).trim().toLowerCase();
return s === "1" || s === "true" || s === "yes" || s === "on";
}
/** Parse /api/radio/config (flat radio_* settings map) into the bits we need. */
function parseConfig(data: unknown): RadioConfig {
const c = isRecord(data) ? data : {};
// radio_enabled defaults to off when the key is absent.
const enabled = "radio_enabled" in c ? isTruthyFlag(c.radio_enabled) : false;
return {
enabled,
streamUrl: asString(c.radio_stream_url).trim(),
name: asString(c.radio_name).trim() || "Radio",
};
const c = isRecord(data) ? data : {};
// radio_enabled defaults to off when the key is absent.
const enabled = "radio_enabled" in c ? isTruthyFlag(c.radio_enabled) : false;
return {
enabled,
streamUrl: asString(c.radio_stream_url).trim(),
name: asString(c.radio_name).trim() || "Radio",
};
}
/**
@@ -62,295 +62,336 @@ function parseConfig(data: unknown): RadioConfig {
* nesting our proxy may surface. Falls back to a generic label.
*/
function parseNowPlaying(data: unknown): string {
if (!isRecord(data)) {
const s = asString(data).trim();
return s || "Live radio";
}
if (!isRecord(data)) {
const s = asString(data).trim();
return s || "Live radio";
}
const joinSongArtist = (song: unknown, artist: unknown): string | null => {
const s = asString(song).trim();
const a = asString(artist).trim();
if (s && a) return `${a} — ${s}`;
if (s) return s;
return null;
};
const joinSongArtist = (song: unknown, artist: unknown): string | null => {
const s = asString(song).trim();
const a = asString(artist).trim();
if (s && a) return `${a} — ${s}`;
if (s) return s;
return null;
};
// Our proxy's raw-text fallback shape: { nowPlaying: "…" }.
const np = asString(data.nowPlaying).trim();
if (np) return np;
// Our proxy's raw-text fallback shape: { nowPlaying: "…" }.
const np = asString(data.nowPlaying).trim();
if (np) return np;
// { song, artist } / { title, artist } at the top level.
const top = joinSongArtist(data.song, data.artist) ?? joinSongArtist(data.title, data.artist);
if (top) return top;
// { song, artist } / { title, artist } at the top level.
const top =
joinSongArtist(data.song, data.artist) ??
joinSongArtist(data.title, data.artist);
if (top) return top;
// AzureCast: { now_playing: { song: { title, artist, text } } }.
if (isRecord(data.now_playing)) {
const inner = data.now_playing;
if (isRecord(inner.song)) {
const s = inner.song;
const text = asString(s.text).trim();
if (text) return text;
const made = joinSongArtist(s.title, s.artist);
if (made) return made;
}
const made = joinSongArtist(inner.song, inner.artist) ?? joinSongArtist(inner.title, inner.artist);
if (made) return made;
}
// AzureCast: { now_playing: { song: { title, artist, text } } }.
if (isRecord(data.now_playing)) {
const inner = data.now_playing;
if (isRecord(inner.song)) {
const s = inner.song;
const text = asString(s.text).trim();
if (text) return text;
const made = joinSongArtist(s.title, s.artist);
if (made) return made;
}
const made =
joinSongArtist(inner.song, inner.artist) ??
joinSongArtist(inner.title, inner.artist);
if (made) return made;
}
return "Live radio";
return "Live radio";
}
/** Extract the current DJ / show name when the provider exposes one. */
function parseDj(data: unknown): string {
if (!isRecord(data)) return "";
const candidates: unknown[] = [data.dj, data.show_name, data.streamer, data.show];
if (isRecord(data.live)) {
candidates.push(data.live.streamer_name, data.live.streamer);
}
for (const c of candidates) {
if (isRecord(c)) {
const name = asString(c.username).trim() || asString(c.show_name).trim() || asString(c.name).trim();
if (name) return name;
} else {
const s = asString(c).trim();
if (s) return s;
}
}
return "";
if (!isRecord(data)) return "";
const candidates: unknown[] = [
data.dj,
data.show_name,
data.streamer,
data.show,
];
if (isRecord(data.live)) {
candidates.push(data.live.streamer_name, data.live.streamer);
}
for (const c of candidates) {
if (isRecord(c)) {
const name =
asString(c.username).trim() ||
asString(c.show_name).trim() ||
asString(c.name).trim();
if (name) return name;
} else {
const s = asString(c).trim();
if (s) return s;
}
}
return "";
}
/** Extract the listener count from /api/radio/listeners ({ listeners }). */
function parseListeners(data: unknown): number | null {
if (!isRecord(data)) return null;
const v = data.listeners;
if (typeof v === "number" && Number.isFinite(v)) return v;
return null;
if (!isRecord(data)) return null;
const v = data.listeners;
if (typeof v === "number" && Number.isFinite(v)) return v;
return null;
}
export default function RadioPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState(50); // 0–100
const [nowPlaying, setNowPlaying] = useState("Loading…");
const [dj, setDj] = useState("");
const [listeners, setListeners] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState(50); // 0–100
const [nowPlaying, setNowPlaying] = useState("Loading…");
const [dj, setDj] = useState("");
const [listeners, setListeners] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
useEffect(() => {
let cancelled = false;
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
useEffect(() => {
let cancelled = false;
async function getJson(url: string): Promise<unknown | null> {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function getJson(url: string): Promise<unknown | null> {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function refresh() {
const [cfg, np, lst] = await Promise.all([
getJson("/api/radio/config"),
getJson("/api/radio/now-playing"),
getJson("/api/radio/listeners"),
]);
if (cancelled) return;
async function refresh() {
const [cfg, np, lst] = await Promise.all([
getJson("/api/radio/config"),
getJson("/api/radio/now-playing"),
getJson("/api/radio/listeners"),
]);
if (cancelled) return;
if (cfg !== null) setConfig(parseConfig(cfg));
if (np !== null) {
setNowPlaying(parseNowPlaying(np));
setDj(parseDj(np));
}
if (lst !== null) setListeners(parseListeners(lst));
}
if (cfg !== null) setConfig(parseConfig(cfg));
if (np !== null) {
setNowPlaying(parseNowPlaying(np));
setDj(parseDj(np));
}
if (lst !== null) setListeners(parseListeners(lst));
}
void refresh();
const id = setInterval(() => void refresh(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
void refresh();
const id = setInterval(() => void refresh(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
// Keep the <audio> volume in sync with the slider.
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume / 100;
}, [volume]);
// Keep the <audio> volume in sync with the slider.
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume / 100;
}, [volume]);
const toggle = useCallback(async () => {
const audio = audioRef.current;
const streamUrl = config?.streamUrl;
if (!audio || !streamUrl) return;
const toggle = useCallback(async () => {
const audio = audioRef.current;
const streamUrl = config?.streamUrl;
if (!audio || !streamUrl) return;
if (playing) {
audio.pause();
setPlaying(false);
return;
}
if (playing) {
audio.pause();
setPlaying(false);
return;
}
setError(null);
// (Re)point at the live stream each time we start so we never replay a
// buffered segment of a continuous broadcast.
if (audio.src !== streamUrl) audio.src = streamUrl;
audio.volume = volume / 100;
try {
await audio.play();
setPlaying(true);
} catch {
setError("Couldn't start playback. Check your browser settings.");
setPlaying(false);
}
}, [config?.streamUrl, playing, volume]);
setError(null);
// (Re)point at the live stream each time we start so we never replay a
// buffered segment of a continuous broadcast.
if (audio.src !== streamUrl) audio.src = streamUrl;
audio.volume = volume / 100;
try {
await audio.play();
setPlaying(true);
} catch {
setError("Couldn't start playback. Check your browser settings.");
setPlaying(false);
}
}, [config?.streamUrl, playing, volume]);
// Disabled or unconfigured → render nothing (and ship no widget UI).
if (!config || !config.enabled || !config.streamUrl) return null;
// Disabled or unconfigured → render nothing (and ship no widget UI).
if (!config?.enabled || !config.streamUrl) return null;
const surface = "var(--color-surface)";
const primary = "var(--color-primary)";
const text = "var(--color-text)";
const muted = "var(--color-text-muted)";
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
const surface = "var(--color-surface)";
const primary = "var(--color-primary)";
const text = "var(--color-text)";
const muted = "var(--color-text-muted)";
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
return (
<section
aria-label={`${config.name} player`}
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
zIndex: 9999,
width: "min(20rem, calc(100vw - 2rem))",
background: surface,
color: text,
border: `1px solid ${border}`,
borderRadius: "14px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
padding: "0.7rem 0.85rem",
fontFamily: "inherit",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<button
type="button"
onClick={() => void toggle()}
aria-label={playing ? "Pause radio" : "Play radio"}
aria-pressed={playing}
style={{
flex: "none",
width: "38px",
height: "38px",
borderRadius: "999px",
display: "grid",
placeItems: "center",
border: "none",
cursor: "pointer",
background: primary,
color: "var(--button-text-color-readable, var(--button-text-color))",
fontSize: "1rem",
lineHeight: 1,
}}
>
<span aria-hidden>{playing ? "⏸" : "▶"}</span>
</button>
return (
<section
aria-label={`${config.name} player`}
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
zIndex: 9999,
width: "min(20rem, calc(100vw - 2rem))",
background: surface,
color: text,
border: `1px solid ${border}`,
borderRadius: "14px",
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
padding: "0.7rem 0.85rem",
fontFamily: "inherit",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<button
type="button"
onClick={() => void toggle()}
aria-label={playing ? "Pause radio" : "Play radio"}
aria-pressed={playing}
style={{
flex: "none",
width: "38px",
height: "38px",
borderRadius: "999px",
display: "grid",
placeItems: "center",
border: "none",
cursor: "pointer",
background: primary,
color:
"var(--button-text-color-readable, var(--button-text-color))",
fontSize: "1rem",
lineHeight: 1,
}}
>
<span aria-hidden>{playing ? "⏸" : "▶"}</span>
</button>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "0.7rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: primary,
}}
>
<span
aria-hidden
style={{
width: "8px",
height: "8px",
borderRadius: "999px",
background: playing ? "#16a34a" : muted,
boxShadow: playing ? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)" : "none",
}}
/>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{config.name}
{dj ? ` · ${dj}` : ""}
</span>
</div>
<div
title={nowPlaying}
style={{
marginTop: "0.1rem",
fontSize: "0.8rem",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{nowPlaying}
</div>
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "0.7rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: primary,
}}
>
<span
aria-hidden
style={{
width: "8px",
height: "8px",
borderRadius: "999px",
background: playing ? "#16a34a" : muted,
boxShadow: playing
? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)"
: "none",
}}
/>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{config.name}
{dj ? ` · ${dj}` : ""}
</span>
</div>
<div
title={nowPlaying}
style={{
marginTop: "0.1rem",
fontSize: "0.8rem",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{nowPlaying}
</div>
</div>
<span
aria-label={listeners != null ? `${listeners.toLocaleString()} listeners` : "Listeners"}
style={{
flex: "none",
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.75rem",
fontWeight: 700,
color: muted,
}}
>
<span aria-hidden>👥</span>
<span aria-hidden>{listeners != null ? listeners.toLocaleString() : "--"}</span>
</span>
</div>
<span
aria-label={
listeners != null
? `${listeners.toLocaleString()} listeners`
: "Listeners"
}
style={{
flex: "none",
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.75rem",
fontWeight: 700,
color: muted,
}}
>
<span aria-hidden>👥</span>
<span aria-hidden>
{listeners != null ? listeners.toLocaleString() : "--"}
</span>
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.55rem" }}>
<span aria-hidden style={{ fontSize: "0.85rem", color: muted, lineHeight: 1 }}>
🔈
</span>
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
aria-label="Volume"
style={{
flex: 1,
height: "4px",
cursor: "pointer",
accentColor: "var(--color-primary)",
padding: 0,
}}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginTop: "0.55rem",
}}
>
<span
aria-hidden
style={{ fontSize: "0.85rem", color: muted, lineHeight: 1 }}
>
🔈
</span>
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
aria-label="Volume"
style={{
flex: 1,
height: "4px",
cursor: "pointer",
accentColor: "var(--color-primary)",
padding: 0,
}}
/>
</div>
{error ? (
<p
role="alert"
aria-live="polite"
style={{ margin: "0.45rem 0 0", fontSize: "0.72rem", color: "var(--color-danger)" }}
>
{error}
</p>
) : null}
{error ? (
<p
role="alert"
aria-live="polite"
style={{
margin: "0.45rem 0 0",
fontSize: "0.72rem",
color: "var(--color-danger)",
}}
>
{error}
</p>
) : null}
{/* Hidden stream element. preload="none" so nothing loads until the user
{/* Hidden stream element. preload="none" so nothing loads until the user
hits play (and browsers block autoplay with sound regardless). */}
<audio ref={audioRef} preload="none" />
</section>
);
<audio ref={audioRef} preload="none" />
</section>
);
}
+153 -122
View File
@@ -1,161 +1,192 @@
const CHAR_ORDER = "abcdefghijklmnopqrstuvwxyz0123456789";
interface CharInfo {
char: string;
x: number;
w: number;
char: string;
x: number;
w: number;
}
const fontCache = new Map<
string,
{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }
string,
{
chars: CharInfo[];
h: number;
top: number;
sheet: HTMLImageElement;
avgW: number;
}
>();
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
return data[(y * w + x) * 4 + 3] === 0;
function isTransparent(
data: Uint8ClampedArray,
x: number,
y: number,
w: number,
): boolean {
return data[(y * w + x) * 4 + 3] === 0;
}
function colEmpty(data: Uint8ClampedArray, x: number, w: number, h: number): boolean {
for (let y = 0; y < h; y++) {
if (!isTransparent(data, x, y, w)) return false;
}
return true;
function colEmpty(
data: Uint8ClampedArray,
x: number,
w: number,
h: number,
): boolean {
for (let y = 0; y < h; y++) {
if (!isTransparent(data, x, y, w)) return false;
}
return true;
}
function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharInfo[] {
const chars: CharInfo[] = [];
let ci = 0;
let x = 0;
function scanCharWidths(
data: Uint8ClampedArray,
sw: number,
sh: number,
): CharInfo[] {
const chars: CharInfo[] = [];
let ci = 0;
let x = 0;
while (x < sw && ci < CHAR_ORDER.length) {
// skip transparent columns
while (x < sw && colEmpty(data, x, sw, sh)) x++;
if (x >= sw) break;
while (x < sw && ci < CHAR_ORDER.length) {
// skip transparent columns
while (x < sw && colEmpty(data, x, sw, sh)) x++;
if (x >= sw) break;
const start = x;
// find end of character
while (x < sw && !colEmpty(data, x, sw, sh)) x++;
const end = x;
const start = x;
// find end of character
while (x < sw && !colEmpty(data, x, sw, sh)) x++;
const end = x;
const w = end - start;
if (w > 0) {
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
chars.push({ char: CHAR_ORDER[ci], x: start, w });
ci++;
}
}
const w = end - start;
if (w > 0) {
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
chars.push({ char: CHAR_ORDER[ci], x: start, w });
ci++;
}
}
return chars;
return chars;
}
export async function getFontInfo(
font: string,
): Promise<{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }> {
const cached = fontCache.get(font);
if (cached) return cached;
export async function getFontInfo(font: string): Promise<{
chars: CharInfo[];
h: number;
top: number;
sheet: HTMLImageElement;
avgW: number;
}> {
const cached = fontCache.get(font);
if (cached) return cached;
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`));
img.src = `/assets/fonts/sheets/${font}.gif`;
});
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`));
img.src = `/assets/fonts/sheets/${font}.gif`;
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get 2D context");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get 2D context");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
// measure total height by finding first/last non-empty row
let top = canvas.height,
bottom = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
if (!isTransparent(imageData.data, x, y, canvas.width)) {
top = Math.min(top, y);
bottom = Math.max(bottom, y);
break;
}
}
}
const h = bottom - top + 1;
// measure total height by finding first/last non-empty row
let top = canvas.height,
bottom = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
if (!isTransparent(imageData.data, x, y, canvas.width)) {
top = Math.min(top, y);
bottom = Math.max(bottom, y);
break;
}
}
}
const h = bottom - top + 1;
const avgW = chars.length > 0 ? Math.round(chars.reduce((s, c) => s + c.w, 0) / chars.length) : 12;
const avgW =
chars.length > 0
? Math.round(chars.reduce((s, c) => s + c.w, 0) / chars.length)
: 12;
const result = { chars, h, top, sheet: img, avgW };
fontCache.set(font, result);
return result;
const result = { chars, h, top, sheet: img, avgW };
fontCache.set(font, result);
return result;
}
export function measureText(chars: CharInfo[], text: string, avgW: number): { totalW: number } {
const map = new Map(chars.map((c) => [c.char, c]));
let totalW = 0;
for (const ch of text.toLowerCase()) {
if (ch === " ") {
totalW += avgW;
} else {
const info = map.get(ch);
totalW += info?.w ?? 0;
}
}
return { totalW };
export function measureText(
chars: CharInfo[],
text: string,
avgW: number,
): { totalW: number } {
const map = new Map(chars.map((c) => [c.char, c]));
let totalW = 0;
for (const ch of text.toLowerCase()) {
if (ch === " ") {
totalW += avgW;
} else {
const info = map.get(ch);
totalW += info?.w ?? 0;
}
}
return { totalW };
}
export function getMissingChars(chars: CharInfo[], text: string): Set<string> {
const available = new Set(chars.map((c) => c.char));
const missing = new Set<string>();
for (const ch of text.toLowerCase()) {
if (ch !== " " && !available.has(ch)) {
missing.add(ch);
}
}
return missing;
const available = new Set(chars.map((c) => c.char));
const missing = new Set<string>();
for (const ch of text.toLowerCase()) {
if (ch !== " " && !available.has(ch)) {
missing.add(ch);
}
}
return missing;
}
export function renderToCanvas(
chars: CharInfo[],
h: number,
top: number,
sheet: HTMLImageElement,
text: string,
avgW: number,
dpr = 1,
chars: CharInfo[],
h: number,
top: number,
sheet: HTMLImageElement,
text: string,
avgW: number,
dpr = 1,
): HTMLCanvasElement | null {
const map = new Map(chars.map((c) => [c.char, c]));
const safeText = text.toLowerCase();
const map = new Map(chars.map((c) => [c.char, c]));
const safeText = text.toLowerCase();
let totalW = 0;
for (const ch of safeText) {
totalW += ch === " " ? avgW : (map.get(ch)?.w ?? 0);
}
let totalW = 0;
for (const ch of safeText) {
totalW += ch === " " ? avgW : (map.get(ch)?.w ?? 0);
}
if (totalW === 0) return null;
if (totalW === 0) return null;
const canvas = document.createElement("canvas");
canvas.width = Math.round(totalW * dpr);
canvas.height = Math.round(h * dpr);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.scale(dpr, dpr);
const canvas = document.createElement("canvas");
canvas.width = Math.round(totalW * dpr);
canvas.height = Math.round(h * dpr);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.scale(dpr, dpr);
let x = 0;
for (const ch of safeText) {
if (ch === " ") {
x += avgW;
} else {
const info = map.get(ch);
if (info) {
ctx.drawImage(sheet, info.x, top, info.w, h, x, 0, info.w, h);
x += info.w;
}
}
}
let x = 0;
for (const ch of safeText) {
if (ch === " ") {
x += avgW;
} else {
const info = map.get(ch);
if (info) {
ctx.drawImage(sheet, info.x, top, info.w, h, x, 0, info.w, h);
x += info.w;
}
}
}
return canvas;
return canvas;
}
+135 -91
View File
@@ -9,118 +9,162 @@ import type { ReactNode } from "react";
*/
export function ContentCard({
icon,
title,
subtitle,
action,
children,
className,
padded = true,
icon,
title,
subtitle,
action,
children,
className,
padded = true,
}: {
icon?: string;
title?: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
children?: ReactNode;
className?: string;
padded?: boolean;
icon?: string;
title?: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
children?: ReactNode;
className?: string;
padded?: boolean;
}) {
const hasHead = title != null || icon != null || subtitle != null || action != null;
return (
<div className={`content-card${className ? ` ${className}` : ""}`}>
{hasHead ? (
<div className="content-card-head">
{icon ? (
<span className="content-card-icon" aria-hidden>
{icon}
</span>
) : null}
<div className="content-card-head-text">
{title != null ? <p className="content-card-title">{title}</p> : null}
{subtitle != null ? <p className="content-card-subtitle">{subtitle}</p> : null}
</div>
{action ? <div className="content-card-action">{action}</div> : null}
</div>
) : null}
<div className={padded ? "content-card-body" : undefined}>{children}</div>
</div>
);
const hasHead =
title != null || icon != null || subtitle != null || action != null;
return (
<div className={`content-card${className ? ` ${className}` : ""}`}>
{hasHead ? (
<div className="content-card-head">
{icon ? (
<span className="content-card-icon" aria-hidden>
{icon}
</span>
) : null}
<div className="content-card-head-text">
{title != null ? (
<p className="content-card-title">{title}</p>
) : null}
{subtitle != null ? (
<p className="content-card-subtitle">{subtitle}</p>
) : null}
</div>
{action ? <div className="content-card-action">{action}</div> : null}
</div>
) : null}
<div className={padded ? "content-card-body" : undefined}>{children}</div>
</div>
);
}
/** A value + label tile (profile stats, rare-value summaries, …). */
export function StatBlock({ value, label, icon }: { value: ReactNode; label: string; icon?: string }) {
return (
<div className="stat-block">
<div className="stat-block-value">
{icon ? <span aria-hidden>{icon} </span> : null}
{value}
</div>
<div className="stat-block-label">{label}</div>
</div>
);
export function StatBlock({
value,
label,
icon,
}: {
value: ReactNode;
label: string;
icon?: string;
}) {
return (
<div className="stat-block">
<div className="stat-block-value">
{icon ? <span aria-hidden>{icon} </span> : null}
{value}
</div>
<div className="stat-block-label">{label}</div>
</div>
);
}
/** Friendly empty state with an icon and a muted line. */
export function EmptyState({ icon = "📭", children }: { icon?: string; children: ReactNode }) {
return (
<div className="empty-state">
<div className="empty-state-icon" aria-hidden>
{icon}
</div>
<p className="muted" style={{ margin: 0 }}>
{children}
</p>
</div>
);
export function EmptyState({
icon = "📭",
children,
}: {
icon?: string;
children: ReactNode;
}) {
return (
<div className="empty-state">
<div className="empty-state-icon" aria-hidden>
{icon}
</div>
<p className="muted" style={{ margin: 0 }}>
{children}
</p>
</div>
);
}
/** Online / offline pill with a status dot. */
export function OnlineBadge({ online }: { online: boolean }) {
return (
<span className={`online-badge${online ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{online ? "Online" : "Offline"}
</span>
);
return (
<span className={`online-badge${online ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
{online ? "Online" : "Offline"}
</span>
);
}
/** Leaderboard rank chip — medals for the top three, number otherwise. */
export function RankBadge({ position }: { position: number }) {
const medal = position === 1 ? "🥇" : position === 2 ? "🥈" : position === 3 ? "🥉" : null;
return (
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>{medal ?? `#${position}`}</span>
);
const medal =
position === 1
? "🥇"
: position === 2
? "🥈"
: position === 3
? "🥉"
: null;
return (
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>
{medal ?? `#${position}`}
</span>
);
}
/* ── Skeleton loaders ────────────────────────────────────────── */
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
return (
<div
className={className}
style={{
...style,
backgroundColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
borderRadius: "6px",
animation: "shimmer 1.5s ease-in-out infinite",
}}
/>
);
export function Skeleton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
return (
<div
className={className}
style={{
...style,
backgroundColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
borderRadius: "6px",
animation: "shimmer 1.5s ease-in-out infinite",
}}
/>
);
}
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
return (
<div className="content-card" style={{ padding: "1rem" }}>
<div className="content-card-head" style={{ margin: "-1rem -1rem 1rem" }}>
<Skeleton style={{ width: "44px", height: "44px", borderRadius: "999px" }} />
<div style={{ flex: 1, display: "grid", gap: "0.35rem" }}>
<Skeleton style={{ height: "16px", width: "60%" }} />
<Skeleton style={{ height: "12px", width: "40%" }} />
</div>
</div>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} style={{ height: "12px", width: `${85 - i * 10}%`, marginBottom: "0.6rem" }} />
))}
</div>
);
return (
<div className="content-card" style={{ padding: "1rem" }}>
<div className="content-card-head" style={{ margin: "-1rem -1rem 1rem" }}>
<Skeleton
style={{ width: "44px", height: "44px", borderRadius: "999px" }}
/>
<div style={{ flex: 1, display: "grid", gap: "0.35rem" }}>
<Skeleton style={{ height: "16px", width: "60%" }} />
<Skeleton style={{ height: "12px", width: "40%" }} />
</div>
</div>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
style={{
height: "12px",
width: `${85 - i * 10}%`,
marginBottom: "0.6rem",
}}
/>
))}
</div>
);
}
+6 -6
View File
@@ -4,10 +4,10 @@ import { useEffect } from "react";
/** Registers the service worker (PWA) once, after hydration. No-op on failure. */
export function PwaRegister() {
useEffect(() => {
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}, []);
return null;
useEffect(() => {
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}, []);
return null;
}
+33 -25
View File
@@ -1,32 +1,40 @@
import { type AvatarOptions, getAvatarUrl } from "@/lib/imager";
interface AvatarImageProps {
figure: string;
options?: AvatarOptions;
width: number;
height: number;
alt: string;
className?: string;
figure: string;
options?: AvatarOptions;
width: number;
height: number;
alt: string;
className?: string;
}
export function AvatarImage({ figure, options, width, height, alt, className = "" }: AvatarImageProps) {
// Auto-pick the smallest size that still looks crisp at the requested
// dimensions, unless the caller passed an explicit size.
const max = Math.max(width, height);
const autoSize: "s" | "m" | "l" =
options?.size ?? (options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
export function AvatarImage({
figure,
options,
width,
height,
alt,
className = "",
}: AvatarImageProps) {
// Auto-pick the smallest size that still looks crisp at the requested
// dimensions, unless the caller passed an explicit size.
const max = Math.max(width, height);
const autoSize: "s" | "m" | "l" =
options?.size ??
(options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
);
}
+17 -17
View File
@@ -3,22 +3,22 @@ import { CmsInfoPopup } from "@/components/cms-info-popup";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteFooter() {
const hotelName = await siteSettings.get("hotel_name", "Atom");
const year = new Date().getFullYear();
const t = await getTranslations("footer");
const hotelName = await siteSettings.get("hotel_name", "Atom");
const year = new Date().getFullYear();
const t = await getTranslations("footer");
return (
<footer
className="mt-auto flex min-h-14 w-full flex-col items-center justify-center gap-1 px-4 py-3 text-sm md:flex-row md:gap-3 md:px-8"
style={{
backgroundColor: "var(--color-surface)",
color: "var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
<div className="md:font-semibold text-[12px] md:text-[14px]">
{t("copyright", { year, hotel: hotelName ?? "Atom" })}
</div>
<CmsInfoPopup />
</footer>
);
return (
<footer
className="mt-auto flex min-h-14 w-full flex-col items-center justify-center gap-1 px-4 py-3 text-sm md:flex-row md:gap-3 md:px-8"
style={{
backgroundColor: "var(--color-surface)",
color: "var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
<div className="md:font-semibold text-[12px] md:text-[14px]">
{t("copyright", { year, hotel: hotelName ?? "Atom" })}
</div>
<CmsInfoPopup />
</footer>
);
}
+112 -97
View File
@@ -1,113 +1,128 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
const t = await getTranslations("header");
const [_session, hotelName, header, logo] = await Promise.all([
auth(),
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("cms_header", "/assets/images/background.png"),
siteSettings.get("cms_logo", ""),
]);
const t = await getTranslations("header");
const [_session, hotelName, header, logo] = await Promise.all([
auth(),
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("cms_header", "/assets/images/background.png"),
siteSettings.get("cms_logo", ""),
]);
let online: number;
try {
online = await cached("online_count", 10_000, () => prisma.user.count({ where: { online: "1" } }));
} catch {
online = 0;
}
let online: number;
try {
online = await cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }),
);
} catch {
online = 0;
}
const bannerUrl =
header && header !== "/assets/images/background.png" ? header : "/assets/images/banner.png";
const bannerUrl =
header && header !== "/assets/images/background.png"
? header
: "/assets/images/banner.png";
return (
<div
className="site-header relative w-full overflow-hidden"
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
>
{/* Background image */}
<div className="absolute inset-0" style={{ background: `url(${bannerUrl}) center/cover no-repeat` }} />
return (
<div
className="site-header relative w-full overflow-hidden"
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
>
{/* Background image */}
<div
className="absolute inset-0"
style={{ background: `url(${bannerUrl}) center/cover no-repeat` }}
/>
{/* Gradient overlays */}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.25) 35%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.4) 75%, rgba(0,0,0,0.7) 100%)",
}}
/>
{/* Gradient overlays */}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.25) 35%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.4) 75%, rgba(0,0,0,0.7) 100%)",
}}
/>
{/* Subtle dot pattern */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
{/* Subtle dot pattern */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"radial-gradient(rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
{/* Decorative glows */}
<div
className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.12] blur-[120px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.08] blur-[100px]"
style={{ background: "var(--color-primary)" }}
/>
{/* Decorative glows */}
<div
className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.12] blur-[120px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.08] blur-[100px]"
style={{ background: "var(--color-primary)" }}
/>
{/* Content */}
<div className="relative flex flex-col items-center justify-center px-4 py-8 md:py-12 min-h-[clamp(12rem,30vw,18rem)]">
<Link href="/" className="transition-transform duration-300 hover:scale-105 mb-8">
{logo ? (
<Image
className="drop-shadow-2xl block"
src={logo}
alt={hotelName ?? "Hotel"}
width={200}
height={120}
style={{ maxHeight: 120, width: "auto", height: "auto" }}
unoptimized
/>
) : (
<h1
className="text-center font-extrabold tracking-tight leading-none text-white"
style={{
fontSize: "clamp(2.5rem, 7vw, 4.5rem)",
textShadow: "0 2px 20px rgba(0,0,0,0.4), 0 8px 40px rgba(0,0,0,0.2)",
letterSpacing: "-0.02em",
}}
>
{hotelName} - Hotel
</h1>
)}
</Link>
{/* Content */}
<div className="relative flex flex-col items-center justify-center px-4 py-8 md:py-12 min-h-[clamp(12rem,30vw,18rem)]">
<Link
href="/"
className="transition-transform duration-300 hover:scale-105 mb-8"
>
{logo ? (
<Image
className="drop-shadow-2xl block"
src={logo}
alt={hotelName ?? "Hotel"}
width={200}
height={120}
style={{ maxHeight: 120, width: "auto", height: "auto" }}
unoptimized
/>
) : (
<h1
className="text-center font-extrabold tracking-tight leading-none text-white"
style={{
fontSize: "clamp(2.5rem, 7vw, 4.5rem)",
textShadow:
"0 2px 20px rgba(0,0,0,0.4), 0 8px 40px rgba(0,0,0,0.2)",
letterSpacing: "-0.02em",
}}
>
{hotelName} - Hotel
</h1>
)}
</Link>
{/* Online badge */}
<div
className="flex items-center gap-3 px-6 py-3 rounded-full"
style={{
background: "rgba(255,255,255,0.08)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
border: "1px solid rgba(255,255,255,0.10)",
boxShadow: "0 4px 24px rgba(0,0,0,0.12)",
}}
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold tracking-wide" style={{ color: "rgba(255,255,255,0.9)" }}>
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
</div>
);
{/* Online badge */}
<div
className="flex items-center gap-3 px-6 py-3 rounded-full"
style={{
background: "rgba(255,255,255,0.08)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
border: "1px solid rgba(255,255,255,0.10)",
boxShadow: "0 4px 24px rgba(0,0,0,0.12)",
}}
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span
className="text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
</div>
);
}
+28 -26
View File
@@ -9,32 +9,34 @@ import { useState } from "react";
* theme before paint to avoid a flash — this component only reflects/toggles it.
*/
export function ThemeSwitcher() {
const [dark, setDark] = useState(
() => typeof window !== "undefined" && document.documentElement.classList.contains("dark"),
);
const [dark, setDark] = useState(
() =>
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark"),
);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch {
/* private mode / storage disabled — runtime toggle still works */
}
}
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch {
/* private mode / storage disabled — runtime toggle still works */
}
}
return (
<button
type="button"
onClick={toggle}
className="nav-item cursor-pointer text-lg leading-none px-2"
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
title={dark ? "Light mode" : "Dark mode"}
>
<span aria-hidden className="text-lg leading-none">
{dark ? "☀" : "☾"}
</span>
</button>
);
return (
<button
type="button"
onClick={toggle}
className="nav-item cursor-pointer text-lg leading-none px-2"
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
title={dark ? "Light mode" : "Dark mode"}
>
<span aria-hidden className="text-lg leading-none">
{dark ? "☀" : "☾"}
</span>
</button>
);
}
+39 -39
View File
@@ -3,44 +3,44 @@ import { themePaletteCss } from "@/lib/theme-css";
import { PRESETS } from "@/lib/theme-presets";
describe("themePaletteCss", () => {
it("emits separate preset-specific light and dark scopes", () => {
const ocean = PRESETS.Ocean;
const css = `${themePaletteCss(":root", ocean.light)}${themePaletteCss("html.dark", ocean.dark)}`;
it("emits separate preset-specific light and dark scopes", () => {
const ocean = PRESETS.Ocean;
const css = `${themePaletteCss(":root", ocean.light)}${themePaletteCss("html.dark", ocean.dark)}`;
expect(css).toContain(":root{");
expect(css).toContain("html.dark{");
expect(css).toContain(`--color-primary:${ocean.light.color_primary}`);
expect(css).toContain(`--color-background:${ocean.light.color_background}`);
expect(css).toContain(`--color-background:${ocean.dark.color_background}`);
expect(ocean.dark.color_primary).toBe(ocean.light.color_primary);
for (const variable of [
"--color-text-subtle-readable",
"--color-text-disabled-readable",
"--admin-sidebar-text-readable",
"--color-success-text-readable",
"--color-success-foreground-readable",
"--color-warning-text-readable",
"--color-error-text-readable",
"--color-info-text-readable",
"--admin-canvas",
"--admin-surface",
"--admin-surface-elevated",
"--admin-text",
"--admin-text-muted",
"--admin-border",
"--admin-accent",
"--admin-accent-foreground",
"--admin-overlay",
"--admin-focus-ring",
"--admin-success",
"--admin-warning",
"--admin-error",
"--admin-info",
"--admin-success-subtle",
"--admin-warning-subtle",
"--admin-error-border",
"--admin-info-foreground",
])
expect(css).toContain(variable);
});
expect(css).toContain(":root{");
expect(css).toContain("html.dark{");
expect(css).toContain(`--color-primary:${ocean.light.color_primary}`);
expect(css).toContain(`--color-background:${ocean.light.color_background}`);
expect(css).toContain(`--color-background:${ocean.dark.color_background}`);
expect(ocean.dark.color_primary).toBe(ocean.light.color_primary);
for (const variable of [
"--color-text-subtle-readable",
"--color-text-disabled-readable",
"--admin-sidebar-text-readable",
"--color-success-text-readable",
"--color-success-foreground-readable",
"--color-warning-text-readable",
"--color-error-text-readable",
"--color-info-text-readable",
"--admin-canvas",
"--admin-surface",
"--admin-surface-elevated",
"--admin-text",
"--admin-text-muted",
"--admin-border",
"--admin-accent",
"--admin-accent-foreground",
"--admin-overlay",
"--admin-focus-ring",
"--admin-success",
"--admin-warning",
"--admin-error",
"--admin-info",
"--admin-success-subtle",
"--admin-warning-subtle",
"--admin-error-border",
"--admin-info-foreground",
])
expect(css).toContain(variable);
});
});
+215 -175
View File
@@ -1,177 +1,217 @@
import { siteSettings } from "@/lib/services/site-settings";
import {
deriveAdminPalette,
derivePublicForegrounds,
readableColor,
} from "@/lib/theme-contrast";
import { adminPaletteCss, themePaletteCss } from "@/lib/theme-css";
import { FONTS, PRESETS, THEME_COLOR_KEYS, type ThemePalette } from "@/lib/theme-presets";
import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
import {
FONTS,
PRESETS,
THEME_COLOR_KEYS,
type ThemePalette,
} from "@/lib/theme-presets";
// Injects the DB-driven CSS custom properties into :root, exactly like
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
// colours, typography, buttons, links + an optional raw custom-CSS block.
export async function ThemeVars() {
const g = (k: string, d: string) => siteSettings.get(k, d);
const [
primary,
background,
surface,
dropdown,
navbar,
navbarText,
text,
textMuted,
accent,
success,
warning,
error,
info,
buttonColor,
buttonText,
buttonSecondary,
buttonSecondaryText,
buttonDanger,
buttonDangerText,
buttonOutline,
buttonOutlineText,
linkColor,
linkHover,
borderColor,
gradientFrom,
gradientTo,
borderRadius,
fontKey,
h1,
h2,
h3,
customCss,
adminCanvas,
adminSurface,
adminText,
adminTextMuted,
adminBorder,
adminSidebarBg,
] = await Promise.all([
g("color_primary", "#f59e0b"),
g("color_background", "#f8fafc"),
g("color_surface", "#ffffff"),
g("color_dropdown", "#ffffff"),
g("color_navbar", "#ffffff"),
g("color_navbar_text", "#1e293b"),
g("color_text", "#0f172a"),
g("color_text_muted", "#64748b"),
g("color_accent", "#10b981"),
g("color_success", "#16a34a"),
g("color_warning", "#eab308"),
g("color_error", "#ef4444"),
g("color_info", "#0ea5e9"),
g("button_primary_color", "#f59e0b"),
g("button_text_color", "#1e293b"),
g("button_secondary_color", "#22c55e"),
g("button_secondary_text_color", "#ffffff"),
g("button_danger_color", "#ef4444"),
g("button_danger_text_color", "#ffffff"),
g("button_outline_color", "#eeb425"),
g("button_outline_text_color", "#1a1a2e"),
g("link_color", "#eeb425"),
g("link_hover_color", "#cf9d15"),
g("border_color", "#eeb425"),
g("gradient_from", "#f59e0b"),
g("gradient_to", "#10b981"),
g("border_radius", "12"),
g("font_family", "nunito"),
g("size_heading_h1", "30"),
g("size_heading_h2", "22"),
g("size_heading_h3", "18"),
g("custom_css", ""),
g("admin_canvas", ""),
g("admin_surface", ""),
g("admin_text", ""),
g("admin_text_muted", ""),
g("admin_border", ""),
g("admin_sidebar_bg", ""),
]);
const g = (k: string, d: string) => siteSettings.get(k, d);
const [
primary,
background,
surface,
dropdown,
navbar,
navbarText,
text,
textMuted,
accent,
success,
warning,
error,
info,
buttonColor,
buttonText,
buttonSecondary,
buttonSecondaryText,
buttonDanger,
buttonDangerText,
buttonOutline,
buttonOutlineText,
linkColor,
linkHover,
borderColor,
gradientFrom,
gradientTo,
borderRadius,
fontKey,
h1,
h2,
h3,
customCss,
adminCanvas,
adminSurface,
adminText,
adminTextMuted,
adminBorder,
adminSidebarBg,
] = await Promise.all([
g("color_primary", "#f59e0b"),
g("color_background", "#f8fafc"),
g("color_surface", "#ffffff"),
g("color_dropdown", "#ffffff"),
g("color_navbar", "#ffffff"),
g("color_navbar_text", "#1e293b"),
g("color_text", "#0f172a"),
g("color_text_muted", "#64748b"),
g("color_accent", "#10b981"),
g("color_success", "#16a34a"),
g("color_warning", "#eab308"),
g("color_error", "#ef4444"),
g("color_info", "#0ea5e9"),
g("button_primary_color", "#f59e0b"),
g("button_text_color", "#1e293b"),
g("button_secondary_color", "#22c55e"),
g("button_secondary_text_color", "#ffffff"),
g("button_danger_color", "#ef4444"),
g("button_danger_text_color", "#ffffff"),
g("button_outline_color", "#eeb425"),
g("button_outline_text_color", "#1a1a2e"),
g("link_color", "#eeb425"),
g("link_hover_color", "#cf9d15"),
g("border_color", "#eeb425"),
g("gradient_from", "#f59e0b"),
g("gradient_to", "#10b981"),
g("border_radius", "12"),
g("font_family", "nunito"),
g("size_heading_h1", "30"),
g("size_heading_h2", "22"),
g("size_heading_h3", "18"),
g("custom_css", ""),
g("admin_canvas", ""),
g("admin_surface", ""),
g("admin_text", ""),
g("admin_text_muted", ""),
g("admin_border", ""),
g("admin_sidebar_bg", ""),
]);
// Only hex/keyword color values are admin-controlled — sanitize defensively.
const safe = (v: string | null, d: string) => (v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d);
const px = (v: string | null, d: string) => (/^\d{1,3}$/.test(v ?? "") ? (v as string) : d);
// Only hex/keyword color values are admin-controlled — sanitize defensively.
const safe = (v: string | null, d: string) =>
v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d;
const px = (v: string | null, d: string) =>
/^\d{1,3}$/.test(v ?? "") ? (v as string) : d;
const safeBackground = safe(background, "#f8fafc");
const safeSurface = safe(surface, "#ffffff");
const publicBackgrounds = [safeBackground, safeSurface];
const readableText = readableColor(safe(text, "#0f172a"), publicBackgrounds);
const readableMuted = readableColor(safe(textMuted, "#64748b"), publicBackgrounds);
const readablePrimary = readableColor(safe(primary, "#f59e0b"), publicBackgrounds);
const readableAccent = readableColor(safe(accent, "#10b981"), publicBackgrounds);
const readableLink = readableColor(safe(linkColor, "#eeb425"), publicBackgrounds);
const readableLinkHover = readableColor(safe(linkHover, "#cf9d15"), publicBackgrounds);
const readableButtonText = readableColor(safe(buttonText, "#1e293b"), [safe(buttonColor, "#f59e0b")]);
const readableSecondaryText = readableColor(safe(buttonSecondaryText, "#ffffff"), [
safe(buttonSecondary, "#22c55e"),
]);
const readableDangerText = readableColor(safe(buttonDangerText, "#ffffff"), [
safe(buttonDanger, "#ef4444"),
]);
const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [safe(navbar, "#ffffff")]);
const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [safe(primary, "#f59e0b")]);
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
const adminOverrides = {
...(adminCanvas ? { canvas: safe(adminCanvas, "") } : {}),
...(adminSurface ? { surface: safe(adminSurface, "") } : {}),
...(adminText ? { text: safe(adminText, "") } : {}),
...(adminTextMuted ? { muted: safe(adminTextMuted, "") } : {}),
...(adminBorder ? { border: safe(adminBorder, "") } : {}),
...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "") } : {}),
};
const safeBackground = safe(background, "#f8fafc");
const safeSurface = safe(surface, "#ffffff");
const publicBackgrounds = [safeBackground, safeSurface];
const readableText = readableColor(safe(text, "#0f172a"), publicBackgrounds);
const readableMuted = readableColor(
safe(textMuted, "#64748b"),
publicBackgrounds,
);
const readablePrimary = readableColor(
safe(primary, "#f59e0b"),
publicBackgrounds,
);
const readableAccent = readableColor(
safe(accent, "#10b981"),
publicBackgrounds,
);
const readableLink = readableColor(
safe(linkColor, "#eeb425"),
publicBackgrounds,
);
const readableLinkHover = readableColor(
safe(linkHover, "#cf9d15"),
publicBackgrounds,
);
const readableButtonText = readableColor(safe(buttonText, "#1e293b"), [
safe(buttonColor, "#f59e0b"),
]);
const readableSecondaryText = readableColor(
safe(buttonSecondaryText, "#ffffff"),
[safe(buttonSecondary, "#22c55e")],
);
const readableDangerText = readableColor(safe(buttonDangerText, "#ffffff"), [
safe(buttonDanger, "#ef4444"),
]);
const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [
safe(navbar, "#ffffff"),
]);
const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [
safe(primary, "#f59e0b"),
]);
const readableAccentForeground = readableColor("#ffffff", [
safe(accent, "#10b981"),
]);
const adminOverrides = {
...(adminCanvas ? { canvas: safe(adminCanvas, "") } : {}),
...(adminSurface ? { surface: safe(adminSurface, "") } : {}),
...(adminText ? { text: safe(adminText, "") } : {}),
...(adminTextMuted ? { muted: safe(adminTextMuted, "") } : {}),
...(adminBorder ? { border: safe(adminBorder, "") } : {}),
...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "") } : {}),
};
const semantic = derivePublicForegrounds({
color_background: safeBackground,
color_surface: safeSurface,
color_dropdown: safe(dropdown, "#ffffff"),
color_navbar: safe(navbar, "#ffffff"),
color_navbar_text: safe(navbarText, "#1e293b"),
color_text: safe(text, "#0f172a"),
color_text_muted: safe(textMuted, "#64748b"),
color_primary: safe(primary, "#f59e0b"),
color_accent: safe(accent, "#10b981"),
button_primary_color: safe(buttonColor, "#f59e0b"),
button_text_color: safe(buttonText, "#1e293b"),
button_secondary_color: safe(buttonSecondary, "#22c55e"),
button_secondary_text_color: safe(buttonSecondaryText, "#ffffff"),
button_danger_color: safe(buttonDanger, "#ef4444"),
button_danger_text_color: safe(buttonDangerText, "#ffffff"),
button_outline_color: safe(buttonOutline, "#eeb425"),
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"),
link_color: safe(linkColor, "#eeb425"),
link_hover_color: safe(linkHover, "#cf9d15"),
});
const semantic = derivePublicForegrounds({
color_background: safeBackground,
color_surface: safeSurface,
color_dropdown: safe(dropdown, "#ffffff"),
color_navbar: safe(navbar, "#ffffff"),
color_navbar_text: safe(navbarText, "#1e293b"),
color_text: safe(text, "#0f172a"),
color_text_muted: safe(textMuted, "#64748b"),
color_primary: safe(primary, "#f59e0b"),
color_accent: safe(accent, "#10b981"),
button_primary_color: safe(buttonColor, "#f59e0b"),
button_text_color: safe(buttonText, "#1e293b"),
button_secondary_color: safe(buttonSecondary, "#22c55e"),
button_secondary_text_color: safe(buttonSecondaryText, "#ffffff"),
button_danger_color: safe(buttonDanger, "#ef4444"),
button_danger_text_color: safe(buttonDangerText, "#ffffff"),
button_outline_color: safe(buttonOutline, "#eeb425"),
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"),
link_color: safe(linkColor, "#eeb425"),
link_hover_color: safe(linkHover, "#cf9d15"),
});
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
const presetName = (await g("theme_preset", "Atom (golden)")) ?? "Atom (golden)";
const preset = PRESETS[presetName] ?? PRESETS["Atom (golden)"];
const darkPalette = Object.fromEntries(
await Promise.all(
THEME_COLOR_KEYS.map(
async (key) => [key, safe(await g(`${key}_dark`, preset.dark[key]), preset.dark[key])] as const,
),
),
) as ThemePalette;
const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
const presetName =
(await g("theme_preset", "Atom (golden)")) ?? "Atom (golden)";
const preset = PRESETS[presetName] ?? PRESETS["Atom (golden)"];
const darkPalette = Object.fromEntries(
await Promise.all(
THEME_COLOR_KEYS.map(
async (key) =>
[
key,
safe(await g(`${key}_dark`, preset.dark[key]), preset.dark[key]),
] as const,
),
),
) as ThemePalette;
const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
const lightAdmin = deriveAdminPalette(
{
color_background: safeBackground,
color_surface: safeSurface,
color_text: safe(text, "#0f172a"),
color_text_muted: safe(textMuted, "#64748b"),
color_primary: safe(primary, "#f59e0b"),
color_accent: safe(accent, "#10b981"),
color_success: safe(success, "#16a34a"),
color_warning: safe(warning, "#eab308"),
color_error: safe(error, "#ef4444"),
color_info: safe(info, "#0ea5e9"),
} as ThemePalette,
adminOverrides,
);
const lightAdmin = deriveAdminPalette(
{
color_background: safeBackground,
color_surface: safeSurface,
color_text: safe(text, "#0f172a"),
color_text_muted: safe(textMuted, "#64748b"),
color_primary: safe(primary, "#f59e0b"),
color_accent: safe(accent, "#10b981"),
color_success: safe(success, "#16a34a"),
color_warning: safe(warning, "#eab308"),
color_error: safe(error, "#ef4444"),
color_info: safe(info, "#0ea5e9"),
} as ThemePalette,
adminOverrides,
);
const css = `:root{${adminPaletteCss(lightAdmin)}
const css = `:root{${adminPaletteCss(lightAdmin)}
--color-primary:${safe(primary, "#f59e0b")};
--color-background:${safeBackground};
--color-surface:${safeSurface};
@@ -223,20 +263,20 @@ export async function ThemeVars() {
--size-heading-h3:${px(h3, "18")}px;
}${darkCss}`;
const googleHref = font.google
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
: null;
const googleHref = font.google
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
: null;
return (
<>
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: server-built CSS from sanitized admin settings */}
<style dangerouslySetInnerHTML={{ __html: css }} />
{customCss && customCss.trim() ? (
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
// biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored theme CSS
<style dangerouslySetInnerHTML={{ __html: customCss }} />
) : null}
</>
);
return (
<>
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: server-built CSS from sanitized admin settings */}
<style dangerouslySetInnerHTML={{ __html: css }} />
{customCss?.trim() ? (
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
// biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored theme CSS
<style dangerouslySetInnerHTML={{ __html: customCss }} />
) : null}
</>
);
}
+205 -155
View File
@@ -1,170 +1,220 @@
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { auth, signOut } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
function Currency({ icon, amount, label }: { icon: string; amount: number; label: string }) {
return (
<div className="flex gap-x-3 sm:gap-x-2 items-center">
<div className={`h-[25px] w-[25px] rounded-full outline-offset-[3px] ${icon}`} />
<div
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text-muted)))",
}}
>
<span
className="font-semibold"
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))" }}
>
{amount.toLocaleString()}
</span>{" "}
<span>{label}</span>
</div>
</div>
);
function Currency({
icon,
amount,
label,
}: {
icon: string;
amount: number;
label: string;
}) {
return (
<div className="flex gap-x-3 sm:gap-x-2 items-center">
<div
className={`h-[25px] w-[25px] rounded-full outline-offset-[3px] ${icon}`}
/>
<div
style={{
color:
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text-muted)))",
}}
>
<span
className="font-semibold"
style={{
color:
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
{amount.toLocaleString()}
</span>{" "}
<span>{label}</span>
</div>
</div>
);
}
export async function TopHeader() {
const session = await auth();
if (!session?.user?.id) return null;
const id = Number(session.user.id);
const session = await auth();
if (!session?.user?.id) return null;
const id = Number(session.user.id);
let credits = 0;
let duckets = 0;
let diamonds = 0;
let look = "";
try {
const [user, currencies] = await Promise.all([
prisma.user.findUnique({ where: { id }, select: { credits: true, look: true } }),
prisma.usersCurrency.findMany({ where: { userId: id } }),
]);
credits = user?.credits ?? 0;
look = user?.look ?? "";
for (const c of currencies) {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {
// render without live balances
}
let credits = 0;
let duckets = 0;
let diamonds = 0;
let look = "";
try {
const [user, currencies] = await Promise.all([
prisma.user.findUnique({
where: { id },
select: { credits: true, look: true },
}),
prisma.usersCurrency.findMany({ where: { userId: id } }),
]);
credits = user?.credits ?? 0;
look = user?.look ?? "";
for (const c of currencies) {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {
// render without live balances
}
const imager =
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
const avatar = avatarImageUrl(imager, look, { headOnly: true, direction: 2 });
const isStaff = (session.user.rank ?? 0) >= 7;
const imager =
(await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
const avatar = avatarImageUrl(imager, look, { headOnly: true, direction: 2 });
const isStaff = (session.user.rank ?? 0) >= 7;
return (
<div style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="max-w-7xl min-h-[60px] px-4 flex items-center justify-between mx-auto">
<div className="flex flex-wrap gap-x-3 sm:gap-x-6">
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
<Currency icon="nav-diamond-icon" amount={diamonds} label="Diamonds" />
</div>
return (
<div style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="max-w-7xl min-h-[60px] px-4 flex items-center justify-between mx-auto">
<div className="flex flex-wrap gap-x-3 sm:gap-x-6">
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
<Currency
icon="nav-diamond-icon"
amount={diamonds}
label="Diamonds"
/>
</div>
<div className="flex items-center gap-x-1 sm:gap-x-3">
{isStaff ? (
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold [&::-webkit-details-marker]:hidden"
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
Administration
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
>
<Link href="/admin" className="dropdown-item" role="menuitem">
Admin panel
</Link>
<Link href="/admin/commandocentrum" className="dropdown-item" role="menuitem">
Commandocentrum
</Link>
<Link href="/admin/logs" className="dropdown-item" role="menuitem">
Logs
</Link>
<Link href="/logo" className="dropdown-item" role="menuitem">
Logo generator
</Link>
</div>
</details>
) : null}
<div className="flex items-center gap-x-1 sm:gap-x-3">
{isStaff ? (
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold [&::-webkit-details-marker]:hidden"
style={{
color:
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
Administration
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
>
<Link href="/admin" className="dropdown-item" role="menuitem">
Admin panel
</Link>
<Link
href="/admin/commandocentrum"
className="dropdown-item"
role="menuitem"
>
Commandocentrum
</Link>
<Link
href="/admin/logs"
className="dropdown-item"
role="menuitem"
>
Logs
</Link>
<Link href="/logo" className="dropdown-item" role="menuitem">
Logo generator
</Link>
</div>
</details>
) : null}
<details className="relative">
<summary
aria-label="User menu"
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
>
<Image
className="bg-no-repeat bg-center"
src={avatar}
alt=""
width={54}
height={62}
unoptimized
/>
<span
className="-ml-2 font-semibold"
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
{session.user.name}
</span>
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
>
<Link href="/me" className="dropdown-item" role="menuitem">
My dashboard
</Link>
<Link href={`/u/${session.user.name}`} className="dropdown-item" role="menuitem">
My profile
</Link>
<Link href="/settings" className="dropdown-item" role="menuitem">
User settings
</Link>
<Link href="/settings/2fa" className="dropdown-item" role="menuitem">
Two-factor
</Link>
<Link href="/settings/sessions" className="dropdown-item" role="menuitem">
Login activity
</Link>
<div
className="h-px my-1 mx-3"
style={{ backgroundColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)" }}
/>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
role="menuitem"
className="dropdown-item w-full text-center mx-1 rounded-lg"
style={{
backgroundColor: "color-mix(in srgb, var(--button-danger-color) 12%, transparent)",
color: "var(--button-danger-color)",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
>
Logout
</button>
</form>
</div>
</details>
</div>
</div>
</div>
);
<details className="relative">
<summary
aria-label="User menu"
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
>
<Image
className="bg-no-repeat bg-center"
src={avatar}
alt=""
width={54}
height={62}
unoptimized
/>
<span
className="-ml-2 font-semibold"
style={{
color:
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
{session.user.name}
</span>
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
>
<Link href="/me" className="dropdown-item" role="menuitem">
My dashboard
</Link>
<Link
href={`/u/${session.user.name}`}
className="dropdown-item"
role="menuitem"
>
My profile
</Link>
<Link href="/settings" className="dropdown-item" role="menuitem">
User settings
</Link>
<Link
href="/settings/2fa"
className="dropdown-item"
role="menuitem"
>
Two-factor
</Link>
<Link
href="/settings/sessions"
className="dropdown-item"
role="menuitem"
>
Login activity
</Link>
<div
className="h-px my-1 mx-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
role="menuitem"
className="dropdown-item w-full text-center mx-1 rounded-lg"
style={{
backgroundColor:
"color-mix(in srgb, var(--button-danger-color) 12%, transparent)",
color: "var(--button-danger-color)",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
>
Logout
</button>
</form>
</div>
</details>
</div>
</div>
</div>
);
}
+34 -31
View File
@@ -5,41 +5,44 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+49 -47
View File
@@ -5,58 +5,60 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+67 -40
View File
@@ -3,63 +3,90 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />
);
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+21 -18
View File
@@ -6,24 +6,27 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+153 -106
View File
@@ -3,134 +3,181 @@
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import type * as React from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
);
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};
+87 -72
View File
@@ -5,101 +5,116 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8", className)}
{...props}
/>
);
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
};
+33 -28
View File
@@ -3,38 +3,43 @@
import { cn } from "@/lib/utils";
interface DateTimePickerProps {
value?: Date | string | null;
onChange: (date: Date | null) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
showTime?: boolean;
value?: Date | string | null;
onChange: (date: Date | null) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
showTime?: boolean;
}
function inputValue(value: Date | string | null | undefined, showTime: boolean): string {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, showTime ? 16 : 10);
function inputValue(
value: Date | string | null | undefined,
showTime: boolean,
): string {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, showTime ? 16 : 10);
}
export function DateTimePicker({
value,
onChange,
placeholder,
disabled = false,
className,
showTime = true,
value,
onChange,
placeholder,
disabled = false,
className,
showTime = true,
}: DateTimePickerProps) {
return (
<input
aria-label={placeholder ?? (showTime ? "Date and time" : "Date")}
className={cn("w-full", className)}
disabled={disabled}
type={showTime ? "datetime-local" : "date"}
value={inputValue(value, showTime)}
onChange={(event) => onChange(event.target.value ? new Date(event.target.value) : null)}
/>
);
return (
<input
aria-label={placeholder ?? (showTime ? "Date and time" : "Date")}
className={cn("w-full", className)}
disabled={disabled}
type={showTime ? "datetime-local" : "date"}
value={inputValue(value, showTime)}
onChange={(event) =>
onChange(event.target.value ? new Date(event.target.value) : null)
}
/>
);
}
+118 -101
View File
@@ -6,135 +6,152 @@ import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+198 -163
View File
@@ -6,217 +6,252 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+13 -13
View File
@@ -3,19 +3,19 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+14 -11
View File
@@ -5,17 +5,20 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+62 -37
View File
@@ -5,60 +5,85 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />
);
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <div data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="popover-description" className={cn("text-muted-foreground", className)} {...props} />;
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
+151 -129
View File
@@ -6,163 +6,185 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+7 -3
View File
@@ -1,9 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />
);
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
+22 -22
View File
@@ -6,30 +6,30 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+87 -50
View File
@@ -5,75 +5,112 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
{...props}
/>
);
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
+68 -58
View File
@@ -7,75 +7,85 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
{...props}
/>
);
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };
+10 -10
View File
@@ -3,16 +3,16 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+37 -27
View File
@@ -6,42 +6,52 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };