This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 "{debounced}"
|
||||
</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 "{debounced}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user