style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

+7 -7
View File
@@ -1,13 +1,13 @@
import { ChevronRight, Home } from 'lucide-react'
import Link from 'next/link'
import { Fragment } from 'react'
import { ChevronRight, Home } from "lucide-react";
import Link from "next/link";
import { Fragment } from "react";
export type { BreadcrumbItem } from '@/types/common'
export type { BreadcrumbItem } from "@/types/common";
import type { BreadcrumbItem } from '@/types/common'
import type { BreadcrumbItem } from "@/types/common";
interface AdminBreadcrumbProps {
items: BreadcrumbItem[]
items: BreadcrumbItem[];
}
export function AdminBreadcrumb({ items }: AdminBreadcrumbProps) {
@@ -29,5 +29,5 @@ export function AdminBreadcrumb({ items }: AdminBreadcrumbProps) {
</Fragment>
))}
</nav>
)
);
}
+3 -1
View File
@@ -32,7 +32,9 @@ export function AdminNavLink({
}`}
>
{icon ? (
<span className={`flex-none ${active ? "text-[var(--color-primary-readable)]" : "text-[var(--admin-sidebar-muted-readable)]"}`}>
<span
className={`flex-none ${active ? "text-[var(--color-primary-readable)]" : "text-[var(--admin-sidebar-muted-readable)]"}`}
>
{icon}
</span>
) : null}
@@ -1,31 +1,31 @@
'use client'
"use client";
import { ChevronRight, Home } from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { useCatalogManager } from './use-catalog-store'
import { ChevronRight, Home } from "lucide-react";
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
dispatch({ type: "SET_ANCESTORS", ancestors: [] });
return;
}
const ac = new AbortController()
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 ?? [] })
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
}
})
.catch(() => {})
return () => ac.abort()
}, [selectedPageId, dispatch])
.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">
@@ -35,7 +35,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: 'SELECT_PAGE', id: a.id })}
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
@@ -44,17 +44,15 @@ export function BreadcrumbBar() {
))}
{/* Current page */}
{(() => {
const node = state.nodes.get(selectedPageId)
if (!node) return null
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>
<span className="shrink-0 text-foreground font-medium truncate max-w-40">{node.caption}</span>
</>
)
);
})()}
</div>
)
);
}
@@ -1,28 +1,22 @@
'use client'
"use client";
import { LayoutGrid } from 'lucide-react'
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 { TooltipProvider } from '@/components/ui/tooltip'
import type { RootTab } from '@/types/catalog'
import { BreadcrumbBar } from './breadcrumb-bar'
import { InlineEditor, type PageUpdateData } from './inline-editor'
import { RootTabs } from './root-tabs'
import { SortableTree } from './sortable-tree'
import { StatsBar } from './stats-bar'
import { CatalogManagerProvider, useCatalogManager } from './use-catalog-store'
import { LayoutGrid } from "lucide-react";
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 { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
import { RootTabs } from "./root-tabs";
import { SortableTree } from "./sortable-tree";
import { 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}>
@@ -41,7 +35,7 @@ export function CatalogManagerDialog() {
</CatalogManagerProvider>
</DialogContent>
</Dialog>
)
);
}
function CatalogManagerInner({
@@ -49,96 +43,93 @@ function CatalogManagerInner({
// 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 })
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 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 })
}));
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 })
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 })
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId])
}, [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
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])
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' })
}
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
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')
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')
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 })
}
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' })
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 })
}
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 })
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)
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
if (rootIdx !== -1) {
const pages = [...state.rootPages]
pages[rootIdx] = { ...pages[rootIdx], caption: updated.caption, enabled: updated.enabled }
dispatch({ type: 'SET_ROOT_PAGES', pages })
const pages = [...state.rootPages];
pages[rootIdx] = { ...pages[rootIdx], caption: updated.caption, enabled: updated.enabled };
dispatch({ type: "SET_ROOT_PAGES", pages });
}
}
};
return (
<TooltipProvider delayDuration={300}>
@@ -178,5 +169,5 @@ function CatalogManagerInner({
{/* Stats bar */}
<StatsBar />
</TooltipProvider>
)
);
}
@@ -1,90 +1,88 @@
'use client'
"use client";
import { ImageOff, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
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',
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
setSrc("");
setStatus("idle");
setFallback(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current)
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFallback(false)
const url = `${basePath}/${value.trim()}.png`
setSrc(url)
setStatus('loading')
}, 300)
setFallback(false);
const url = `${basePath}/${value.trim()}.png`;
setSrc(url);
setStatus("loading");
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [value, basePath])
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')
setFallback(true);
setSrc(`${basePath}/${value.trim()}.gif`);
setStatus("loading");
} else {
setStatus('error')
setStatus("error");
}
}
if (status === 'idle' || !src) {
if (status === "idle" || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ''}`}
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 || ''}`}
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" />
)}
{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')}
style={{ imageRendering: "pixelated" }}
onLoad={() => setStatus("loaded")}
onError={handleError}
/>
{status === 'error' && (
{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>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertCircle,
@@ -10,133 +10,127 @@ import {
Package,
Save,
Settings,
} from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { updateCatalogPage } from '@/actions/catalog'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogItemsTable } from '@/app/admin/catalog/[id]/catalog-items-table'
import { CatalogTranslateTab } from '@/app/admin/catalog/[id]/catalog-translate-tab'
import { BulkImportItems } from '@/components/admin/catalog/bulk-import-items'
import { CatalogImagePicker } from '@/components/admin/catalog/catalog-image-picker'
import { IconPicker } from '@/components/admin/catalog/icon-picker'
import { ItemsShopPreview } from '@/components/admin/catalog/items-shop-preview'
import { QuickAddFurni } from '@/components/admin/catalog/quick-add-furni'
import { CatalogIcon, LAYOUT_COLORS } from '@/components/admin/catalog-tree'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from '@/lib/catalog-layouts'
import { cn } from '@/lib/utils'
import { ImagePreview } from './image-preview'
import { useCatalogManager } from './use-catalog-store'
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogItemsTable } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS } from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
import { useCatalogManager } from "./use-catalog-store";
interface PageDetail {
id: number
caption: string
parentId: number
pageLayout: string
enabled: string
visible: string
minRank: number
clubOnly: string
orderNum: number
iconImage: number
iconColor: number
pageHeadline: string
pageTeaser: string
pageSpecial: string | null
pageText1: string | null
pageText2: string | null
pageTextDetails: string | null
pageTextTeaser: string | null
itemCount?: number
id: number;
caption: string;
parentId: number;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
clubOnly: string;
orderNum: number;
iconImage: number;
iconColor: number;
pageHeadline: string;
pageTeaser: string;
pageSpecial: string | null;
pageText1: string | null;
pageText2: string | null;
pageTextDetails: string | null;
pageTextTeaser: string | null;
itemCount?: number;
}
interface ItemsData {
items: CatalogItemData[]
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[]
catalogNameMap: Record<number, string>
furniDataIdList: number[]
furniDescriptionMap: Record<number, string>
furniRevisionMap: Record<number, { classname: string; revision: number }>
interactionTypes: string[]
allPages: { id: number; caption: string }[]
items: CatalogItemData[];
baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string }[];
catalogNameMap: Record<number, string>;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[];
allPages: { id: number; caption: string }[];
}
export interface PageUpdateData {
id: number
caption: string
enabled: string
visible: string
pageLayout: string
iconImage: number
id: number;
caption: string;
enabled: string;
visible: string;
pageLayout: string;
iconImage: number;
}
interface InlineEditorProps {
pageId: number | null
onSaved: (updated: PageUpdateData) => void
pageId: number | null;
onSaved: (updated: PageUpdateData) => void;
}
type FormState = Record<string, string | number | null>
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch } = useCatalogManager()
const [page, setPage] = useState<PageDetail | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<FormState>({})
const [original, setOriginal] = useState<FormState>({})
const [showTexts, setShowTexts] = useState(false)
const [activeTab, setActiveTab] = useState('settings')
const [showPreview, setShowPreview] = useState(false)
const { dispatch } = useCatalogManager();
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
const [activeTab, setActiveTab] = useState("settings");
const [showPreview, setShowPreview] = useState(false);
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null)
const [itemsLoading, setItemsLoading] = useState(false)
const [itemsLoaded, setItemsLoaded] = useState(false)
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
return Object.keys(form).some((k) => form[k] !== original[k])
}, [form, original])
return Object.keys(form).some((k) => form[k] !== original[k]);
}, [form, original]);
useEffect(() => {
dispatch({ type: 'SET_DIRTY', dirty: isDirty })
}, [isDirty, dispatch])
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (isDirty && !saving && page) handleSave()
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (isDirty && !saving && page) handleSave();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}) // eslint-disable-line react-hooks/exhaustive-deps
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}); // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => {
setLoading(true)
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
const p = data.page as PageDetail
setPage(p)
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
@@ -147,84 +141,84 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || '',
pageTeaser: p.pageTeaser || '',
pageSpecial: p.pageSpecial || '',
pageText1: p.pageText1 || '',
pageText2: p.pageText2 || '',
pageTextDetails: p.pageTextDetails || '',
pageTextTeaser: p.pageTextTeaser || '',
}
setForm(formData)
setOriginal(formData)
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error('Failed to load page')
setPage(null)
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true)
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`)
if (!res.ok) throw new Error()
const data = await res.json()
setItemsData(data as ItemsData)
setItemsLoaded(true)
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
toast.error('Failed to load items')
toast.error("Failed to load items");
} finally {
setItemsLoading(false)
setItemsLoading(false);
}
}, [])
}, []);
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId)
}, [pageId, loadItemsData])
if (pageId && pageId > 0) loadItemsData(pageId);
}, [pageId, loadItemsData]);
useEffect(() => {
if (pageId && pageId > 0) {
loadPage(pageId)
setItemsData(null)
setItemsLoaded(false)
setActiveTab('settings')
setShowPreview(false)
loadPage(pageId);
setItemsData(null);
setItemsLoaded(false);
setActiveTab("settings");
setShowPreview(false);
} else {
setPage(null)
setForm({})
setOriginal({})
setItemsData(null)
setItemsLoaded(false)
setPage(null);
setForm({});
setOriginal({});
setItemsData(null);
setItemsLoaded(false);
}
}, [pageId, loadPage])
}, [pageId, loadPage]);
// Lazy-load items
useEffect(() => {
if (
(activeTab === 'items' || activeTab === 'translate') &&
(activeTab === "items" || activeTab === "translate") &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
pageId > 0
) {
loadItemsData(pageId)
loadItemsData(pageId);
}
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData])
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]);
function update(key: string, value: string | number | null) {
setForm((prev) => ({ ...prev, [key]: value }))
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
if (!page) return
setSaving(true)
if (!page) return;
setSaving(true);
try {
const result = await updateCatalogPage({ id: page.id, ...form })
if (!result.ok) throw new Error(result.error || 'Failed to save')
toast.success('Page updated & catalog synced')
setOriginal({ ...form })
const result = await updateCatalogPage({ id: page.id, ...form });
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
setOriginal({ ...form });
setPage((prev) =>
prev
? {
@@ -236,7 +230,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
iconImage: (form.iconImage as number) ?? prev.iconImage,
}
: prev,
)
);
onSaved({
id: page.id,
caption: String(form.caption || page.caption),
@@ -244,11 +238,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
visible: String(form.visible ?? page.visible),
pageLayout: String(form.pageLayout || page.pageLayout),
iconImage: (form.iconImage as number) ?? page.iconImage,
})
});
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save')
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false)
setSaving(false);
}
}
@@ -257,7 +251,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Select a page from the tree to edit
</div>
)
);
}
if (loading) {
@@ -265,12 +259,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
);
}
if (!page) return null
if (!page) return null;
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || 'bg-muted text-muted-foreground'
const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
return (
<div className="flex-1 flex flex-col min-h-0">
@@ -281,13 +275,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{(form.caption as string) || page.caption}</h3>
<Badge className={cn('h-4 px-1 text-[8px] font-mono border-0', layoutColor)}>
<Badge className={cn("h-4 px-1 text-[8px] font-mono border-0", layoutColor)}>
{form.pageLayout as string}
</Badge>
{isDirty && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
unsaved
@@ -295,7 +289,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
)}
</div>
<p className="text-xs text-muted-foreground">
#{page.id} &middot; Parent: {page.parentId === -1 ? 'Root' : `#${page.parentId}`}
#{page.id} &middot; Parent: {page.parentId === -1 ? "Root" : `#${page.parentId}`}
{page.itemCount !== undefined && ` · ${page.itemCount} items`}
</p>
</div>
@@ -313,9 +307,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{' '}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to
save
Unsaved changes — press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">Ctrl+S</kbd> to save
</span>
<Button
size="sm"
@@ -324,11 +317,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Save className="mr-1 h-3 w-3" />
)}
{saving ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <Save className="mr-1 h-3 w-3" />}
Save
</Button>
</div>
@@ -340,7 +329,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />}
{isDirty && <span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
@@ -356,13 +345,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList)
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length
const furniSet = new Set(itemsData.furniDataIdList);
const missing = itemsData.baseItems.filter((b) => !furniSet.has(b.id)).length;
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null
) : null;
})()}
</TabsTrigger>
</TabsList>
@@ -375,36 +364,33 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-1.5">
<Label className="text-xs">Caption</Label>
<Input
value={String(form.caption || '')}
onChange={(e) => update('caption', e.target.value)}
value={String(form.caption || "")}
onChange={(e) => update("caption", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select
value={String(form.pageLayout || '')}
onValueChange={(v) => update('pageLayout', v)}
>
<Select value={String(form.pageLayout || "")} onValueChange={(v) => update("pageLayout", v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color = LAYOUT_COLORS[l] || 'bg-muted text-muted-foreground'
const color = LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span className={cn('inline-block h-2 w-2 rounded-full', color)} />
<span className={cn("inline-block h-2 w-2 rounded-full", color)} />
{l}
</span>
</SelectItem>
)
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? 'Custom layout'}
{LAYOUT_DESCRIPTIONS[String(form.pageLayout) as CatalogLayout] ?? "Custom layout"}
</p>
</div>
</div>
@@ -416,7 +402,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Input
type="number"
value={form.orderNum as number}
onChange={(e) => update('orderNum', parseInt(e.target.value, 10) || 0)}
onChange={(e) => update("orderNum", parseInt(e.target.value, 10) || 0)}
className="h-8 text-sm"
/>
</div>
@@ -425,7 +411,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Input
type="number"
value={form.minRank as number}
onChange={(e) => update('minRank', parseInt(e.target.value, 10) || 1)}
onChange={(e) => update("minRank", parseInt(e.target.value, 10) || 1)}
className="h-8 text-sm"
/>
</div>
@@ -433,7 +419,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update('iconImage', v)}
onChange={(v) => update("iconImage", v)}
/>
</div>
</div>
@@ -444,22 +430,22 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
id="ie-enabled"
label="Enabled"
description="Active in the catalog"
checked={form.enabled === '1'}
onChange={(v) => update('enabled', v ? '1' : '0')}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
/>
<ToggleRow
id="ie-visible"
label="Visible"
description="Shown in navigation"
checked={form.visible === '1'}
onChange={(v) => update('visible', v ? '1' : '0')}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === '1'}
onChange={(v) => update('clubOnly', v ? '1' : '0')}
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
</div>
@@ -467,24 +453,24 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || '')}
onChange={(v) => update('pageHeadline', v)}
value={String(form.pageHeadline || "")}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
/>
<ImagePreview value={String(form.pageHeadline || '')} height={50} />
<ImagePreview value={String(form.pageHeadline || "")} height={50} />
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || '')}
onChange={(v) => update('pageTeaser', v)}
value={String(form.pageTeaser || "")}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview value={String(form.pageTeaser || '')} height={50} />
<ImagePreview value={String(form.pageTeaser || "")} height={50} />
</div>
{/* Collapsible text fields */}
@@ -493,11 +479,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{showTexts ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
@@ -512,8 +494,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || '')}
onChange={(e) => update('pageText1', e.target.value || null)}
value={String(form.pageText1 || "")}
onChange={(e) => update("pageText1", e.target.value || null)}
className="text-sm"
/>
</div>
@@ -521,8 +503,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || '')}
onChange={(e) => update('pageText2', e.target.value || null)}
value={String(form.pageText2 || "")}
onChange={(e) => update("pageText2", e.target.value || null)}
className="text-sm"
/>
</div>
@@ -530,16 +512,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || '')}
onChange={(e) => update('pageTextDetails', e.target.value || null)}
value={String(form.pageTextDetails || "")}
onChange={(e) => update("pageTextDetails", e.target.value || null)}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
value={String(form.pageSpecial || '')}
onChange={(e) => update('pageSpecial', e.target.value || null)}
value={String(form.pageSpecial || "")}
onChange={(e) => update("pageSpecial", e.target.value || null)}
className="h-8 text-sm"
/>
</div>
@@ -578,7 +560,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
onClick={() => setShowPreview(!showPreview)}
className="ml-auto"
>
{showPreview ? 'Hide Preview' : 'Shop Preview'}
{showPreview ? "Hide Preview" : "Shop Preview"}
</Button>
</div>
@@ -625,7 +607,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</TabsContent>
</Tabs>
</div>
)
);
}
// ── Toggle row (inline) ─────────────────────────────────────
@@ -637,11 +619,11 @@ function ToggleRow({
checked,
onChange,
}: {
id: string
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
id: string;
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
@@ -653,5 +635,5 @@ function ToggleRow({
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
</div>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
closestCenter,
@@ -9,105 +9,93 @@ import {
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { deleteTreePage, reorderTreePage, toggleCatalogPage } from '@/actions/catalog'
import { CatalogIcon } from '@/components/admin/catalog-tree'
import { Button } from '@/components/ui/button'
} from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { deleteTreePage, reorderTreePage, toggleCatalogPage } from "@/actions/catalog";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type { RootTab } from '@/types/catalog'
export type { RootTab } from "@/types/catalog";
import 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)
}, [])
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 { 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')
);
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')
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'}`)
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
toast.success(`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`);
} catch {
toast.error('Failed to toggle')
toast.error("Failed to toggle");
}
}
};
const handleDelete = async (tab: RootTab) => {
if (!confirm(`Delete root page "${tab.caption}"? Children will be reparented.`)) return
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`)
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
toast.success(`"${tab.caption}" deleted`);
} catch {
toast.error('Failed to delete')
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">
@@ -132,19 +120,12 @@ export function RootTabs({
))}
</SortableContext>
<DragOverlay>
{draggedTab && (
<TabButton tab={draggedTab} isActive={activeTabId === draggedTab.id} isOverlay />
)}
{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}
>
<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>
@@ -153,7 +134,7 @@ export function RootTabs({
</TooltipContent>
</Tooltip>
</div>
)
);
}
// ── Sortable root tab ─────────────────────────────────────────
@@ -166,22 +147,22 @@ function SortableRootTab({
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 style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1,
}
};
return (
<ContextMenu>
@@ -197,12 +178,12 @@ function SortableRootTab({
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggle}>
{tab.enabled === '1' ? (
{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'}
{tab.enabled === "1" ? "Disable" : "Enable"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
@@ -211,7 +192,7 @@ function SortableRootTab({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
);
}
// ── Tab button render ─────────────────────────────────────────
@@ -223,11 +204,11 @@ function TabButton({
onClick,
onDoubleClick,
}: {
tab: RootTab
isActive: boolean
isOverlay?: boolean
onClick?: () => void
onDoubleClick?: () => void
tab: RootTab;
isActive: boolean;
isOverlay?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
}) {
return (
<button
@@ -235,16 +216,16 @@ function TabButton({
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',
"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',
? "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>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
closestCenter,
@@ -9,9 +9,9 @@ import {
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useVirtualizer } from '@tanstack/react-virtual'
} from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ChevronsDownUp,
ChevronsUpDown,
@@ -26,44 +26,39 @@ import {
ToggleRight,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
createCatalogPage,
deleteTreePage,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from '@/actions/catalog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
type FlatTreeNode,
SortableTreeItem,
TreeItemContent,
type TreeItemProps,
} from './tree-item'
import { useCatalogManager } from './use-catalog-store'
} from "@/actions/catalog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { type FlatTreeNode, SortableTreeItem, TreeItemContent, type TreeItemProps } from "./tree-item";
import { useCatalogManager } from "./use-catalog-store";
/* ─── Types ──────────────────────────────────────────────── */
export type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
export type { TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
import type { TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
import type { TreeNodeData, TreeNodeUpdate } from "@/types/catalog";
interface SortableTreeProps {
activeTabId: number | null
selectedPageId: number | null
onSelectPage: (id: number) => void
onTreeMutation: () => void
nodeUpdate?: TreeNodeUpdate | null
activeTabId: number | null;
selectedPageId: number | null;
onSelectPage: (id: number) => void;
onTreeMutation: () => void;
nodeUpdate?: TreeNodeUpdate | null;
}
type FilterMode = 'all' | 'enabled' | 'disabled'
type FilterMode = "all" | "enabled" | "disabled";
/* ─── Component ──────────────────────────────────────────── */
@@ -74,147 +69,144 @@ export function SortableTree({
onTreeMutation,
nodeUpdate,
}: SortableTreeProps) {
const { state, dispatch } = useCatalogManager()
const { nodes, childrenMap, expandedIds, loadingIds, multiSelectedIds, focusedId, sortMode } =
state
const { state, dispatch } = useCatalogManager();
const { nodes, childrenMap, expandedIds, loadingIds, multiSelectedIds, focusedId, sortMode } = state;
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(null)
const [searching, setSearching] = useState(false)
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const [activeId, setActiveId] = useState<number | null>(null)
const [treeLoading, setTreeLoading] = useState(false)
const [filter, setFilter] = useState<FilterMode>('all')
const searchInputRef = useRef<HTMLInputElement>(null)
const treeRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(null);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
const searchInputRef = useRef<HTMLInputElement>(null);
const treeRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// ── Full tree load (single API call) ────────────────────────
const loadFullTree = useCallback(async () => {
setTreeLoading(true)
setTreeLoading(true);
try {
const res = await fetch('/api/admin/catalog/tree?mode=full')
if (!res.ok) throw new Error()
const data = await res.json()
const pages: TreeNodeData[] = data.pages || []
const res = await fetch("/api/admin/catalog/tree?mode=full");
if (!res.ok) throw new Error();
const data = await res.json();
const pages: TreeNodeData[] = data.pages || [];
// Build nodes map and children map in one pass
const nodesMap = new Map<number, TreeNodeData>()
const childMap = new Map<number, number[]>()
const nodesMap = new Map<number, TreeNodeData>();
const childMap = new Map<number, number[]>();
for (const p of pages) {
nodesMap.set(p.id, p)
const siblings = childMap.get(p.parentId) || []
siblings.push(p.id)
childMap.set(p.parentId, siblings)
nodesMap.set(p.id, p);
const siblings = childMap.get(p.parentId) || [];
siblings.push(p.id);
childMap.set(p.parentId, siblings);
}
dispatch({ type: 'SET_NODES', nodes: pages })
dispatch({ type: "SET_NODES", nodes: pages });
// Set all children maps at once
for (const [parentId, childIds] of childMap) {
const children = childIds.map((id) => nodesMap.get(id)!).filter(Boolean)
dispatch({ type: 'SET_CHILDREN', parentId, children })
const children = childIds.map((id) => nodesMap.get(id)!).filter(Boolean);
dispatch({ type: "SET_CHILDREN", parentId, children });
}
} catch {
toast.error('Failed to load catalog tree')
toast.error("Failed to load catalog tree");
} finally {
setTreeLoading(false)
setTreeLoading(false);
}
}, [dispatch])
}, [dispatch]);
// Load full tree when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (activeTabId === null) return
dispatch({ type: 'CLEAR_TREE' })
setSearchQuery('')
setSearchResults(null)
setFilter('all')
loadFullTree()
}, [activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
if (activeTabId === null) return;
dispatch({ type: "CLEAR_TREE" });
setSearchQuery("");
setSearchResults(null);
setFilter("all");
loadFullTree();
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null)
return
setSearchResults(null);
return;
}
if (searchTimeout.current) clearTimeout(searchTimeout.current)
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
setSearching(true)
setSearching(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`,
)
const res = await fetch(`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`);
if (res.ok) {
const data = await res.json()
setSearchResults(data.pages || [])
const data = await res.json();
setSearchResults(data.pages || []);
}
} finally {
setSearching(false)
setSearching(false);
}
}, 300)
}, 300);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current)
}
}, [searchQuery])
if (searchTimeout.current) clearTimeout(searchTimeout.current);
};
}, [searchQuery]);
// Apply external node updates
useEffect(() => {
if (!nodeUpdate) return
dispatch({ type: 'UPDATE_NODE', update: nodeUpdate })
}, [nodeUpdate, dispatch])
if (!nodeUpdate) return;
dispatch({ type: "UPDATE_NODE", update: nodeUpdate });
}, [nodeUpdate, dispatch]);
// Toggle expand (instant — data already loaded)
const handleToggleExpand = useCallback(
(id: number) => {
if (expandedIds.has(id)) {
dispatch({ type: 'SET_EXPANDED', id, expanded: false })
dispatch({ type: "SET_EXPANDED", id, expanded: false });
} else {
dispatch({ type: 'SET_EXPANDED', id, expanded: true })
dispatch({ type: "SET_EXPANDED", id, expanded: true });
}
},
[expandedIds, dispatch],
)
);
// ── Flatten visible tree with filter ────────────────────────
const flatItems = useMemo((): FlatTreeNode[] => {
if (activeTabId === null) return []
if (activeTabId === null) return [];
if (searchResults) {
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }))
return searchResults.map((n) => ({ ...n, depth: 0, isExpanded: false }));
}
const result: FlatTreeNode[] = []
const result: FlatTreeNode[] = [];
const traverse = (parentId: number, depth: number) => {
const childIds = childrenMap.get(parentId) || []
const childIds = childrenMap.get(parentId) || [];
for (const cid of childIds) {
const node = nodes.get(cid)
if (!node) continue
const node = nodes.get(cid);
if (!node) continue;
// Apply filter
if (filter === 'enabled' && node.enabled !== '1') continue
if (filter === 'disabled' && node.enabled === '1') continue
const isExpanded = expandedIds.has(cid)
result.push({ ...node, depth, isExpanded })
if (isExpanded) traverse(cid, depth + 1)
if (filter === "enabled" && node.enabled !== "1") continue;
if (filter === "disabled" && node.enabled === "1") continue;
const isExpanded = expandedIds.has(cid);
result.push({ ...node, depth, isExpanded });
if (isExpanded) traverse(cid, depth + 1);
}
}
traverse(activeTabId, 0)
return result
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter])
};
traverse(activeTabId, 0);
return result;
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
const flatIds = useMemo(() => flatItems.map((n) => n.id), [flatItems])
const totalNodes = nodes.size
const flatIds = useMemo(() => flatItems.map((n) => n.id), [flatItems]);
const totalNodes = nodes.size;
// ── Ancestor highlight ──────────────────────────────────────
const ancestorIds = useMemo(() => {
const set = new Set<number>()
if (!selectedPageId) return set
let current = nodes.get(selectedPageId)
const set = new Set<number>();
if (!selectedPageId) return set;
let current = nodes.get(selectedPageId);
while (current && current.parentId > 0) {
set.add(current.parentId)
current = nodes.get(current.parentId)
set.add(current.parentId);
current = nodes.get(current.parentId);
}
return set
}, [selectedPageId, nodes])
return set;
}, [selectedPageId, nodes]);
// ── Virtualizer ─────────────────────────────────────────────
const virtualizer = useVirtualizer({
@@ -222,86 +214,85 @@ export function SortableTree({
getScrollElement: () => scrollRef.current,
estimateSize: () => 30,
overscan: 20,
})
});
// Auto-scroll to selected page
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!selectedPageId) return
const idx = flatIds.indexOf(selectedPageId)
if (!selectedPageId) return;
const idx = flatIds.indexOf(selectedPageId);
if (idx >= 0) {
// Delay slightly to let virtualizer settle
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: 'center' }))
requestAnimationFrame(() => virtualizer.scrollToIndex(idx, { align: "center" }));
}
}, [selectedPageId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedPageId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keyboard navigation ─────────────────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (e.key === "/") {
e.preventDefault();
searchInputRef.current?.focus();
}
return
return;
}
if (e.key === '/') {
e.preventDefault()
searchInputRef.current?.focus()
return
if (e.key === "/") {
e.preventDefault();
searchInputRef.current?.focus();
return;
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
if (searchQuery) {
setSearchQuery('')
setSearchResults(null)
} else dispatch({ type: 'CLEAR_MULTI_SELECT' })
return
setSearchQuery("");
setSearchResults(null);
} else dispatch({ type: "CLEAR_MULTI_SELECT" });
return;
}
const currentIdx = focusedId ? flatIds.indexOf(focusedId) : -1
const currentIdx = focusedId ? flatIds.indexOf(focusedId) : -1;
if (e.key === 'ArrowDown') {
e.preventDefault()
const nextIdx = currentIdx < flatIds.length - 1 ? currentIdx + 1 : 0
dispatch({ type: 'SET_FOCUSED', id: flatIds[nextIdx] })
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatIds.length - 1
dispatch({ type: 'SET_FOCUSED', id: flatIds[prevIdx] })
} else if (e.key === 'ArrowRight' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIdx = currentIdx < flatIds.length - 1 ? currentIdx + 1 : 0;
dispatch({ type: "SET_FOCUSED", id: flatIds[nextIdx] });
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIdx = currentIdx > 0 ? currentIdx - 1 : flatIds.length - 1;
dispatch({ type: "SET_FOCUSED", id: flatIds[prevIdx] });
} else if (e.key === "ArrowRight" && focusedId) {
e.preventDefault();
const node = flatItems.find((n) => n.id === focusedId);
if (node && node.childCount > 0 && !node.isExpanded) {
handleToggleExpand(focusedId)
handleToggleExpand(focusedId);
} else if (node?.isExpanded) {
const children = childrenMap.get(focusedId)
if (children?.[0]) dispatch({ type: 'SET_FOCUSED', id: children[0] })
const children = childrenMap.get(focusedId);
if (children?.[0]) dispatch({ type: "SET_FOCUSED", id: children[0] });
}
} else if (e.key === 'ArrowLeft' && focusedId) {
e.preventDefault()
const node = flatItems.find((n) => n.id === focusedId)
} else if (e.key === "ArrowLeft" && focusedId) {
e.preventDefault();
const node = flatItems.find((n) => n.id === focusedId);
if (node?.isExpanded) {
dispatch({ type: 'SET_EXPANDED', id: focusedId, expanded: false })
dispatch({ type: "SET_EXPANDED", id: focusedId, expanded: false });
} else {
const parent = nodes.get(focusedId)
if (parent?.parentId && parent.parentId > 0)
dispatch({ type: 'SET_FOCUSED', id: parent.parentId })
const parent = nodes.get(focusedId);
if (parent?.parentId && parent.parentId > 0) dispatch({ type: "SET_FOCUSED", id: parent.parentId });
}
} else if (e.key === 'Enter' && focusedId) {
e.preventDefault()
onSelectPage(focusedId)
} else if (e.key === ' ' && focusedId) {
e.preventDefault()
dispatch({ type: 'TOGGLE_MULTI_SELECT', id: focusedId })
} else if (e.key === 'Delete' && focusedId) {
e.preventDefault()
handleDelete(focusedId)
} else if (e.key === "Enter" && focusedId) {
e.preventDefault();
onSelectPage(focusedId);
} else if (e.key === " " && focusedId) {
e.preventDefault();
dispatch({ type: "TOGGLE_MULTI_SELECT", id: focusedId });
} else if (e.key === "Delete" && focusedId) {
e.preventDefault();
handleDelete(focusedId);
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [
focusedId,
flatIds,
@@ -312,219 +303,218 @@ export function SortableTree({
dispatch,
onSelectPage,
handleToggleExpand,
]) // eslint-disable-line react-hooks/exhaustive-deps
]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Selection with Ctrl/Shift ───────────────────────────────
const handleSelect = useCallback(
(id: number, e?: React.MouseEvent) => {
if (e?.ctrlKey || e?.metaKey) {
dispatch({ type: 'TOGGLE_MULTI_SELECT', id })
dispatch({ type: "TOGGLE_MULTI_SELECT", id });
} else if (e?.shiftKey && focusedId) {
dispatch({ type: 'RANGE_SELECT', from: focusedId, to: id, flatIds })
dispatch({ type: "RANGE_SELECT", from: focusedId, to: id, flatIds });
} else {
onSelectPage(id)
onSelectPage(id);
}
},
[focusedId, flatIds, dispatch, onSelectPage],
)
);
// ── DnD handlers ────────────────────────────────────────────
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id as number)
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id as number);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id || activeTabId === null) return
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || activeTabId === null) return;
const activeNode = nodes.get(active.id as number)
const overNode = nodes.get(over.id as number)
if (!activeNode || !overNode) return
const activeNode = nodes.get(active.id as number);
const overNode = nodes.get(over.id as number);
if (!activeNode || !overNode) return;
if (activeNode.parentId === overNode.parentId) {
const parentId = activeNode.parentId
const siblings = childrenMap.get(parentId) || []
const oldIndex = siblings.indexOf(active.id as number)
const newIndex = siblings.indexOf(over.id as number)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove(siblings, oldIndex, newIndex)
const parentId = activeNode.parentId;
const siblings = childrenMap.get(parentId) || [];
const oldIndex = siblings.indexOf(active.id as number);
const newIndex = siblings.indexOf(over.id as number);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(siblings, oldIndex, newIndex);
dispatch({
type: 'SET_CHILDREN',
type: "SET_CHILDREN",
parentId,
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
})
});
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) => reorderTreePage({ pageId: id, newOrderNum: idx + 1 })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) toast.error(`${failed} reorder(s) failed`)
else toast.success('Reordered')
onTreeMutation()
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
else toast.success("Reordered");
onTreeMutation();
} catch {
toast.error('Failed to reorder')
loadFullTree()
toast.error("Failed to reorder");
loadFullTree();
}
} else {
const newParentId = overNode.parentId
const siblings = childrenMap.get(newParentId) || []
const overIndex = siblings.indexOf(over.id as number)
const newParentId = overNode.parentId;
const siblings = childrenMap.get(newParentId) || [];
const overIndex = siblings.indexOf(over.id as number);
try {
const result = await reorderTreePage({
pageId: active.id as number,
newParentId,
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
})
if (!result.ok) throw new Error()
await loadFullTree()
onTreeMutation()
toast.success('Page moved')
});
if (!result.ok) throw new Error();
await loadFullTree();
onTreeMutation();
toast.success("Page moved");
} catch {
toast.error('Failed to move page')
toast.error("Failed to move page");
}
}
}
};
// ── Actions ─────────────────────────────────────────────────
const handleAddSubpage = async (parentId: number) => {
const caption = prompt('Subpage name:')
if (!caption?.trim()) return
const caption = prompt("Subpage name:");
if (!caption?.trim()) return;
try {
const result = await createCatalogPage({ parentId, caption: caption.trim() })
if (!result.ok) throw new Error(result.error)
dispatch({ type: 'SET_EXPANDED', id: parentId, expanded: true })
await loadFullTree()
const data = result.data as Record<string, unknown>
onSelectPage(data.id as number)
onTreeMutation()
const result = await createCatalogPage({ parentId, caption: caption.trim() });
if (!result.ok) throw new Error(result.error);
dispatch({ type: "SET_EXPANDED", id: parentId, expanded: true });
await loadFullTree();
const data = result.data as Record<string, unknown>;
onSelectPage(data.id as number);
onTreeMutation();
} catch {
toast.error('Failed to create subpage')
toast.error("Failed to create subpage");
}
}
};
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
const newEnabled = currentEnabled === '1' ? '0' : '1'
const newEnabled = currentEnabled === "1" ? "0" : "1";
try {
const result = await toggleCatalogPage({ id, action: 'toggleEnabled' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: newEnabled } })
onTreeMutation()
const result = await toggleCatalogPage({ id, action: "toggleEnabled" });
if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } });
onTreeMutation();
} catch {
toast.error('Failed to toggle enabled')
toast.error("Failed to toggle enabled");
}
}
};
const handleToggleVisible = async (id: number, currentVisible: string) => {
const newVisible = currentVisible === '1' ? '0' : '1'
const newVisible = currentVisible === "1" ? "0" : "1";
try {
const result = await toggleCatalogPage({ id, action: 'toggleVisible' })
if (!result.ok) throw new Error()
dispatch({ type: 'UPDATE_NODE', update: { id, visible: newVisible } })
onTreeMutation()
const result = await toggleCatalogPage({ id, action: "toggleVisible" });
if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } });
onTreeMutation();
} catch {
toast.error('Failed to toggle visibility')
toast.error("Failed to toggle visibility");
}
}
};
const handleDelete = async (id: number) => {
const node = nodes.get(id)
if (!confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`)) return
const node = nodes.get(id);
if (!confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`)) return;
try {
const result = await deleteTreePage({ pageId: id, mode: 'reparent' })
if (!result.ok) throw new Error()
dispatch({ type: 'REMOVE_NODE', id })
if (selectedPageId === id) onSelectPage(-1)
await loadFullTree()
onTreeMutation()
const result = await deleteTreePage({ pageId: id, mode: "reparent" });
if (!result.ok) throw new Error();
dispatch({ type: "REMOVE_NODE", id });
if (selectedPageId === id) onSelectPage(-1);
await loadFullTree();
onTreeMutation();
} catch {
toast.error('Failed to delete page')
toast.error("Failed to delete page");
}
}
};
const handleDuplicate = async (id: number) => {
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`)
if (!res.ok) throw new Error()
const { page } = await res.json()
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const { page } = await res.json();
const result = await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? 'default_3x3',
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: '0',
visible: page.visible ?? '1',
enabled: "0",
visible: page.visible ?? "1",
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
})
if (!result.ok) throw new Error(result.error)
const newId = (result.data as Record<string, unknown>).id as number
});
if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number;
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? '',
pageTeaser: page.pageTeaser ?? '',
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
})
await loadFullTree()
toast.success(`Duplicated "${page.caption}" (items not copied)`)
onSelectPage(newId)
onTreeMutation()
});
await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId);
onTreeMutation();
} catch {
toast.error('Failed to duplicate page')
toast.error("Failed to duplicate page");
}
}
};
const handleOpenFull = (id: number) => window.open(`/admin/catalog/${id}`, '_blank')
const handleOpenFull = (id: number) => window.open(`/admin/catalog/${id}`, "_blank");
// ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => {
const allIds = Array.from(nodes.keys())
dispatch({ type: 'EXPAND_PATH', ids: allIds })
}, [nodes, dispatch])
const allIds = Array.from(nodes.keys());
dispatch({ type: "EXPAND_PATH", ids: allIds });
}, [nodes, dispatch]);
const handleCollapseAll = useCallback(() => {
dispatch({ type: 'COLLAPSE_ALL' })
}, [dispatch])
dispatch({ type: "COLLAPSE_ALL" });
}, [dispatch]);
// ── Bulk actions ────────────────────────────────────────────
const handleBulkEnable = async (enable: boolean) => {
const ids = [...multiSelectedIds]
const ids = [...multiSelectedIds];
const results = await Promise.allSettled(
ids.map((id) => {
const node = nodes.get(id)
if (!node || (node.enabled === '1') === enable) return Promise.resolve()
return toggleCatalogPage({ id, action: 'toggleEnabled' })
const node = nodes.get(id);
if (!node || (node.enabled === "1") === enable) return Promise.resolve();
return toggleCatalogPage({ id, action: "toggleEnabled" });
}),
)
for (const id of ids)
dispatch({ type: 'UPDATE_NODE', update: { id, enabled: enable ? '1' : '0' } })
dispatch({ type: 'CLEAR_MULTI_SELECT' })
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) toast.error(`${failed} toggle(s) failed`)
else toast.success(`${ids.length} page(s) ${enable ? 'enabled' : 'disabled'}`)
onTreeMutation()
}
);
for (const id of ids) dispatch({ type: "UPDATE_NODE", update: { id, enabled: enable ? "1" : "0" } });
dispatch({ type: "CLEAR_MULTI_SELECT" });
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} toggle(s) failed`);
else toast.success(`${ids.length} page(s) ${enable ? "enabled" : "disabled"}`);
onTreeMutation();
};
const handleBulkDelete = async () => {
const ids = [...multiSelectedIds]
if (!confirm(`Delete ${ids.length} page(s)?`)) return
await Promise.allSettled(ids.map((id) => deleteTreePage({ pageId: id, mode: 'reparent' })))
dispatch({ type: 'CLEAR_MULTI_SELECT' })
await loadFullTree()
toast.success(`${ids.length} page(s) deleted`)
onTreeMutation()
}
const ids = [...multiSelectedIds];
if (!confirm(`Delete ${ids.length} page(s)?`)) return;
await Promise.allSettled(ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })));
dispatch({ type: "CLEAR_MULTI_SELECT" });
await loadFullTree();
toast.success(`${ids.length} page(s) deleted`);
onTreeMutation();
};
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null
const noop = () => {}
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
const noop = () => {};
// Shared props builder for tree items
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
const getItemProps = useCallback(
(node: FlatTreeNode): Omit<TreeItemProps, 'sortMode' | 'isOverlay'> => ({
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
node,
isSelected: selectedPageId === node.id,
isFocused: focusedId === node.id,
@@ -540,24 +530,11 @@ export function SortableTree({
onOpenFull: handleOpenFull,
isLoading: loadingIds.has(node.id),
}),
[
selectedPageId,
focusedId,
multiSelectedIds,
ancestorIds,
loadingIds,
handleToggleExpand,
handleSelect,
],
) // eslint-disable-line react-hooks/exhaustive-deps
[selectedPageId, focusedId, multiSelectedIds, ancestorIds, loadingIds, handleToggleExpand, handleSelect],
); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
ref={treeRef}
role="tree"
tabIndex={0}
className="flex flex-col h-full border-r w-80 shrink-0"
>
<div ref={treeRef} role="tree" tabIndex={0} className="flex flex-col h-full border-r w-80 shrink-0">
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
<div className="flex items-center gap-1.5">
@@ -570,10 +547,10 @@ export function SortableTree({
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 pr-7 text-sm"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
setSearchResults(null)
searchInputRef.current?.blur()
if (e.key === "Escape") {
setSearchQuery("");
setSearchResults(null);
searchInputRef.current?.blur();
}
}}
/>
@@ -583,8 +560,8 @@ export function SortableTree({
<button
type="button"
onClick={() => {
setSearchQuery('')
setSearchResults(null)
setSearchQuery("");
setSearchResults(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -595,10 +572,10 @@ export function SortableTree({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={sortMode ? 'default' : 'outline'}
variant={sortMode ? "default" : "outline"}
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dispatch({ type: 'TOGGLE_SORT_MODE' })}
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
>
{sortMode ? (
<GripVertical className="h-3.5 w-3.5" />
@@ -608,7 +585,7 @@ export function SortableTree({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{sortMode ? 'Sort mode' : 'Edit mode'}</p>
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
</TooltipContent>
</Tooltip>
</div>
@@ -617,21 +594,21 @@ export function SortableTree({
<div className="flex items-center gap-1.5">
{/* Status filter */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
{(['all', 'enabled', 'disabled'] as FilterMode[]).map((f) => (
{(["all", "enabled", "disabled"] as FilterMode[]).map((f) => (
<button
type="button"
key={f}
onClick={() => setFilter(f)}
className={cn(
'flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
"flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors",
filter === f
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{f === 'enabled' && <ToggleRight className="h-2.5 w-2.5" />}
{f === 'disabled' && <ToggleLeft className="h-2.5 w-2.5" />}
{f === 'all' ? 'All' : f === 'enabled' ? 'On' : 'Off'}
{f === "enabled" && <ToggleRight className="h-2.5 w-2.5" />}
{f === "disabled" && <ToggleLeft className="h-2.5 w-2.5" />}
{f === "all" ? "All" : f === "enabled" ? "On" : "Off"}
</button>
))}
</div>
@@ -681,7 +658,7 @@ export function SortableTree({
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[9px] h-4 px-1">
{flatItems.length}
{totalNodes > 0 && flatItems.length !== totalNodes ? ` / ${totalNodes}` : ''} pages
{totalNodes > 0 && flatItems.length !== totalNodes ? ` / ${totalNodes}` : ""} pages
</Badge>
{sortMode && (
<Badge variant="default" className="text-[9px] h-4 px-1">
@@ -700,7 +677,7 @@ export function SortableTree({
) : searchResults !== null ? (
<div className="p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
</p>
{flatItems.map((node) => (
<div key={node.id} className="py-px">
@@ -717,22 +694,22 @@ export function SortableTree({
onDragEnd={handleDragEnd}
>
<SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index]
const node = flatItems[vItem.index];
return (
<div
key={node.id}
style={{
position: 'absolute',
position: "absolute",
top: vItem.start,
width: '100%',
width: "100%",
height: vItem.size,
}}
>
<SortableTreeItem {...getItemProps(node)} sortMode={true} />
</div>
)
);
})}
</div>
</SortableContext>
@@ -759,22 +736,22 @@ export function SortableTree({
</DndContext>
) : (
/* Normal mode: virtualized, no dnd-kit overhead */
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((vItem) => {
const node = flatItems[vItem.index]
const node = flatItems[vItem.index];
return (
<div
key={node.id}
style={{
position: 'absolute',
position: "absolute",
top: vItem.start,
width: '100%',
width: "100%",
height: vItem.size,
}}
>
<TreeItemContent {...getItemProps(node)} sortMode={false} />
</div>
)
);
})}
</div>
)}
@@ -807,24 +784,19 @@ export function SortableTree({
>
<PowerOff className="mr-1 h-3 w-3" /> Disable
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 text-[11px] px-2"
onClick={handleBulkDelete}
>
<Button size="sm" variant="destructive" className="h-6 text-[11px] px-2" onClick={handleBulkDelete}>
<Trash2 className="mr-1 h-3 w-3" /> Delete
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[11px] px-2 ml-auto"
onClick={() => dispatch({ type: 'CLEAR_MULTI_SELECT' })}
onClick={() => dispatch({ type: "CLEAR_MULTI_SELECT" })}
>
Clear
</Button>
</div>
)}
</div>
)
);
}
@@ -1,23 +1,23 @@
'use client'
"use client";
import { FolderTree, Keyboard, Package, ToggleRight } from 'lucide-react'
import { useMemo } from 'react'
import { useCatalogManager } from './use-catalog-store'
import { FolderTree, Keyboard, Package, ToggleRight } from "lucide-react";
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)
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])
};
}, [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">
@@ -46,10 +46,9 @@ export function StatsBar() {
<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
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+K</kbd> quick add
</span>
</div>
</div>
)
);
}
@@ -1,7 +1,7 @@
'use client'
"use client";
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronRight,
Copy as CopyIcon,
@@ -17,10 +17,10 @@ import {
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'
} 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,
@@ -28,54 +28,54 @@ import {
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { translateCaption } from '@/lib/catalog-translations'
import { cn } from '@/lib/utils'
} 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 style = {
transform: CSS.Transform.toString(transform),
transition,
}
};
return (
<div ref={setNodeRef} style={style}>
@@ -85,7 +85,7 @@ export function SortableTreeItem(props: TreeItemProps) {
dragHandleProps={props.sortMode ? { ...attributes, ...listeners } : undefined}
/>
</div>
)
);
}
export const TreeItemContent = React.memo(
@@ -109,13 +109,13 @@ export const TreeItemContent = React.memo(
onOpenFull,
isLoading,
}: TreeItemProps & {
isDragging?: boolean
dragHandleProps?: Record<string, unknown>
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 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
@@ -123,26 +123,23 @@ export const TreeItemContent = React.memo(
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',
"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)
if (!sortMode && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onSelect(node.id, e as unknown as React.MouseEvent);
}
}}
>
@@ -161,8 +158,8 @@ export const TreeItemContent = React.memo(
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleExpand(node.id)
e.stopPropagation();
onToggleExpand(node.id);
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-accent"
>
@@ -171,8 +168,8 @@ export const TreeItemContent = React.memo(
) : (
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
node.isExpanded && 'rotate-90',
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
node.isExpanded && "rotate-90",
)}
/>
)}
@@ -209,18 +206,18 @@ export const TreeItemContent = React.memo(
)}
<Badge
className={cn(
'h-4 px-1 text-[8px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
"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')}
{node.pageLayout.replace("default_3x3", "3x3")}
</Badge>
</div>
</div>
)
);
// In sort mode or overlay, no context menu
if (sortMode || isOverlay) return content
if (sortMode || isOverlay) return content;
return (
<ContextMenu>
@@ -255,20 +252,12 @@ export const TreeItemContent = React.memo(
</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'}
{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'}
{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
@@ -280,7 +269,7 @@ export const TreeItemContent = React.memo(
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
);
},
(prev, next) => {
return (
@@ -298,6 +287,6 @@ export const TreeItemContent = React.memo(
prev.sortMode === next.sortMode &&
prev.isLoading === next.isLoading &&
prev.isDragging === next.isDragging
)
);
},
)
);
@@ -1,34 +1,34 @@
'use client'
"use client";
import { createContext, type Dispatch, type ReactNode, useContext, useReducer } from 'react'
import type { RootTab, TreeNodeData, TreeNodeUpdate } from '@/types/catalog'
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
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
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
editorDirty: boolean;
// Breadcrumb
ancestors: Array<{ id: number; caption: string }>
ancestors: Array<{ id: number; caption: string }>;
}
const initialState: CatalogManagerState = {
@@ -43,51 +43,51 @@ const initialState: CatalogManagerState = {
multiSelectedIds: new Set(),
focusedId: null,
sortMode: false,
searchQuery: '',
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':
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,
@@ -95,55 +95,55 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
multiSelectedIds: new Set(),
focusedId: null,
ancestors: [],
}
case 'SELECT_PAGE':
};
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 "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 "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 "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 "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_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[] = []
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)
nextNodes.set(child.id, child);
childIds.push(child.id);
}
nextChildren.set(action.parentId, childIds)
return { ...state, nodes: nextNodes, childrenMap: nextChildren }
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)
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 }),
@@ -151,77 +151,77 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
...(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 }
});
return { ...state, nodes: nextNodes };
}
case 'REMOVE_NODE': {
const nextNodes = new Map(state.nodes)
nextNodes.delete(action.id)
const nextChildren = new Map(state.childrenMap)
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)
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 }
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_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 "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 "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 "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':
case "ON_DIALOG_CLOSE":
return {
...state,
selectedPageId: null,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
}
};
case 'CLEAR_TREE':
case "CLEAR_TREE":
return {
...state,
nodes: new Map(),
@@ -231,36 +231,34 @@ function reducer(state: CatalogManagerState, action: CatalogAction): CatalogMana
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
searchQuery: '',
searchQuery: "",
searchResults: null,
ancestors: [],
}
};
default:
return state
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)
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CatalogManagerContext.Provider value={{ state, dispatch }}>
{children}
</CatalogManagerContext.Provider>
)
<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;
}
+225 -244
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
ChevronRight,
@@ -20,13 +20,13 @@ import {
ToggleRight,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -34,63 +34,63 @@ import {
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { translateCaption } from '@/lib/catalog-translations'
import { cn } from '@/lib/utils'
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { translateCaption } from "@/lib/catalog-translations";
import { cn } from "@/lib/utils";
/* ─── Types ──────────────────────────────────────────────── */
interface TreeNode {
id: number
caption: string
parentId: number
enabled: string
visible: string
iconImage: number
iconColor: number
pageLayout: string
childCount: number
itemCount: number
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
}
interface CatalogTreeProps {
rootPages: TreeNode[]
catalogType?: 'normal' | 'bc'
rootPages: TreeNode[];
catalogType?: "normal" | "bc";
}
/* ─── Context for expand/collapse all ────────────────────── */
interface TreeContext {
expandSignal: number
collapseSignal: number
filterMode: FilterMode
onToggleEnabled: (node: TreeNode) => void
onToggleVisible: (node: TreeNode) => void
onDeletePage: (node: TreeNode) => void
searchQuery: string
catalogType: 'normal' | 'bc'
expandSignal: number;
collapseSignal: number;
filterMode: FilterMode;
onToggleEnabled: (node: TreeNode) => void;
onToggleVisible: (node: TreeNode) => void;
onDeletePage: (node: TreeNode) => void;
searchQuery: string;
catalogType: "normal" | "bc";
}
const TreeCtx = createContext<TreeContext>({
expandSignal: 0,
collapseSignal: 0,
filterMode: 'all',
filterMode: "all",
onToggleEnabled: () => {},
onToggleVisible: () => {},
onDeletePage: () => {},
searchQuery: '',
catalogType: 'normal',
})
searchQuery: "",
catalogType: "normal",
});
/* ─── Catalog Icon ───────────────────────────────────────── */
export function CatalogIcon({ iconImage, size = 20 }: { iconImage: number; size?: number }) {
const [error, setError] = useState(false)
const [error, setError] = useState(false);
if (error || iconImage <= 0) {
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />
return <Package className="h-4 w-4 text-muted-foreground shrink-0" />;
}
return (
@@ -100,175 +100,172 @@ export function CatalogIcon({ iconImage, size = 20 }: { iconImage: number; size?
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: 'pixelated', minWidth: size, minHeight: size }}
style={{ imageRendering: "pixelated", minWidth: size, minHeight: size }}
onError={() => setError(true)}
/>
)
);
}
/* ─── Layout label helper ────────────────────────────────── */
export const LAYOUT_COLORS: Record<string, string> = {
default_3x3: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
frontpage: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]',
spaces_new: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]',
pets: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
pets2: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
pets3: 'bg-[var(--admin-error)]/15 text-[var(--admin-error)]',
marketplace: 'bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]',
recycler: 'bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]',
club_buy: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]',
single_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
room_bundle: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]',
}
default_3x3: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
frontpage: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
spaces_new: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
pets: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets2: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
pets3: "bg-[var(--admin-error)]/15 text-[var(--admin-error)]",
marketplace: "bg-[var(--admin-accent)]/15 text-[var(--admin-accent-text)]",
recycler: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]",
club_buy: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]",
single_bundle: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
room_bundle: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]",
};
/* ─── Highlight matched text ─────────────────────────────── */
function HighlightText({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <>{text}</>
if (!query.trim()) return <>{text}</>;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
<mark className="bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] rounded-sm px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
)
);
}
/* ─── Main Tree Component ────────────────────────────────── */
type FilterMode = 'all' | 'enabled' | 'disabled'
type FilterMode = "all" | "enabled" | "disabled";
export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreeProps) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null)
const [searching, setSearching] = useState(false)
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [expandSignal, setExpandSignal] = useState(0)
const [collapseSignal, setCollapseSignal] = useState(0)
const [filterMode, setFilterMode] = useState<FilterMode>('all')
export function CatalogTree({ rootPages, catalogType = "normal" }: CatalogTreeProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNode[] | null>(null);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [expandSignal, setExpandSignal] = useState(0);
const [collapseSignal, setCollapseSignal] = useState(0);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
// "/" keyboard shortcut to focus search
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
e.preventDefault()
searchInputRef.current?.focus()
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
searchInputRef.current?.focus();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Debounced search
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null)
return
setSearchResults(null);
return;
}
if (searchTimeout.current) clearTimeout(searchTimeout.current)
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
setSearching(true)
setSearching(true);
try {
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`,
)
);
if (res.ok) {
const data = await res.json()
setSearchResults(data.pages || [])
const data = await res.json();
setSearchResults(data.pages || []);
}
} catch (error) {
console.error('[CatalogTree] Search failed:', error)
console.error("[CatalogTree] Search failed:", error);
} finally {
setSearching(false)
setSearching(false);
}
}, 300)
}, 300);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current)
}
}, [searchQuery])
if (searchTimeout.current) clearTimeout(searchTimeout.current);
};
}, [searchQuery]);
// Quick actions from context menu
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const catBody = catalogType === 'bc' ? ', "catalog": "bc"' : ''
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
const catBody = catalogType === "bc" ? ', "catalog": "bc"' : "";
const handleToggleEnabled = useCallback(
async (node: TreeNode) => {
try {
const res = await fetch('/api/admin/catalog/tree', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/admin/catalog/tree", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`,
})
if (!res.ok) throw new Error()
toast.success(`${node.caption} ${node.enabled === '1' ? 'disabled' : 'enabled'}`)
router.refresh()
});
if (!res.ok) throw new Error();
toast.success(`${node.caption} ${node.enabled === "1" ? "disabled" : "enabled"}`);
router.refresh();
} catch {
toast.error('Failed to toggle. Open the page to edit.')
toast.error("Failed to toggle. Open the page to edit.");
}
},
[router, catBody],
)
);
const handleToggleVisible = useCallback(
async (node: TreeNode) => {
try {
const res = await fetch('/api/admin/catalog/tree', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
const res = await fetch("/api/admin/catalog/tree", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`,
})
if (!res.ok) throw new Error()
toast.success(`${node.caption} ${node.visible === '1' ? 'hidden' : 'visible'}`)
router.refresh()
});
if (!res.ok) throw new Error();
toast.success(`${node.caption} ${node.visible === "1" ? "hidden" : "visible"}`);
router.refresh();
} catch {
toast.error('Failed to toggle. Open the page to edit.')
toast.error("Failed to toggle. Open the page to edit.");
}
},
[router, catBody],
)
);
const handleDeletePage = useCallback(
async (node: TreeNode) => {
const childInfo =
node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to parent.` : ''
const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : ''
if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return
node.childCount > 0 ? ` Its ${node.childCount} child page(s) will be moved to parent.` : "";
const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : "";
if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return;
try {
const res = await fetch(
`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`,
{ method: 'DELETE' },
)
if (!res.ok) throw new Error()
toast.success(`"${node.caption}" deleted`)
router.refresh()
const res = await fetch(`/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`, {
method: "DELETE",
});
if (!res.ok) throw new Error();
toast.success(`"${node.caption}" deleted`);
router.refresh();
} catch {
toast.error('Failed to delete page.')
toast.error("Failed to delete page.");
}
},
[router, catParam],
)
);
const unfilteredPages = searchResults ?? rootPages
const unfilteredPages = searchResults ?? rootPages;
const displayPages =
filterMode === 'all'
filterMode === "all"
? unfilteredPages
: unfilteredPages.filter((p) =>
filterMode === 'enabled' ? p.enabled === '1' : p.enabled !== '1',
)
: unfilteredPages.filter((p) => (filterMode === "enabled" ? p.enabled === "1" : p.enabled !== "1"));
return (
<TreeCtx.Provider
@@ -276,7 +273,7 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
expandSignal,
collapseSignal,
filterMode,
searchQuery: searchResults !== null ? searchQuery : '',
searchQuery: searchResults !== null ? searchQuery : "",
onToggleEnabled: handleToggleEnabled,
onToggleVisible: handleToggleVisible,
onDeletePage: handleDeletePage,
@@ -292,7 +289,7 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
<h3 className="text-sm font-semibold">Catalog Structure</h3>
<Badge variant="secondary" className="text-[10px] h-5">
{displayPages.length}
{filterMode !== 'all' ? ` / ${unfilteredPages.length}` : ''} pages
{filterMode !== "all" ? ` / ${unfilteredPages.length}` : ""} pages
</Badge>
</div>
<div className="flex items-center gap-1.5">
@@ -302,12 +299,12 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('all')}
onClick={() => setFilterMode("all")}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors',
filterMode === 'all'
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
"px-2 py-1 rounded text-xs font-medium transition-colors",
filterMode === "all"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
All
@@ -321,12 +318,12 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('enabled')}
onClick={() => setFilterMode("enabled")}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
filterMode === 'enabled'
? 'bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]'
: 'text-muted-foreground hover:text-foreground',
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
filterMode === "enabled"
? "bg-background shadow-sm text-[var(--admin-success)] dark:text-[var(--admin-success)]"
: "text-muted-foreground hover:text-foreground",
)}
>
<ToggleRight className="h-3 w-3" />
@@ -341,12 +338,12 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setFilterMode('disabled')}
onClick={() => setFilterMode("disabled")}
className={cn(
'px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1',
filterMode === 'disabled'
? 'bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]'
: 'text-muted-foreground hover:text-foreground',
"px-2 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1",
filterMode === "disabled"
? "bg-background shadow-sm text-[var(--admin-error)] dark:text-[var(--admin-error)]"
: "text-muted-foreground hover:text-foreground",
)}
>
<ToggleLeft className="h-3 w-3" />
@@ -405,9 +402,9 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
searchInputRef.current?.blur()
if (e.key === "Escape") {
setSearchQuery("");
searchInputRef.current?.blur();
}
}}
/>
@@ -417,7 +414,7 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
onClick={() => setSearchQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
@@ -433,7 +430,7 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
{searchResults !== null && (
<div className="px-2 py-1.5 mb-1 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for &quot;
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""} for &quot;
{searchQuery}&quot;
</p>
</div>
@@ -449,14 +446,14 @@ export function CatalogTree({ rootPages, catalogType = 'normal' }: CatalogTreePr
))}
{displayPages.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{searchResults !== null ? 'No pages match your search' : 'No catalog pages found'}
{searchResults !== null ? "No pages match your search" : "No catalog pages found"}
</p>
)}
</div>
</div>
</TooltipProvider>
</TreeCtx.Provider>
)
);
}
/* ─── Tree Item ──────────────────────────────────────────── */
@@ -468,10 +465,10 @@ function TreeItem({
isSearchResult,
defaultExpanded,
}: {
node: TreeNode
level: number
isSearchResult?: boolean
defaultExpanded?: boolean
node: TreeNode;
level: number;
isSearchResult?: boolean;
defaultExpanded?: boolean;
}) {
const {
expandSignal,
@@ -482,73 +479,73 @@ function TreeItem({
onToggleVisible,
onDeletePage,
catalogType,
} = useContext(TreeCtx)
const router = useRouter()
const [expanded, setExpanded] = useState(false)
const [children, setChildren] = useState<TreeNode[]>([])
const [loading, setLoading] = useState(false)
const [loaded, setLoaded] = useState(false)
const hasChildren = node.childCount > 0
} = useContext(TreeCtx);
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const hasChildren = node.childCount > 0;
// Auto-expand root level on mount
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
loadChildren().then(() => setExpanded(true))
loadChildren().then(() => setExpanded(true));
}
}, [])
}, []);
// Expand all signal
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (expandSignal > 0 && hasChildren) {
if (!loaded) {
loadChildren().then(() => setExpanded(true))
loadChildren().then(() => setExpanded(true));
} else {
setExpanded(true)
setExpanded(true);
}
}
}, [expandSignal])
}, [expandSignal]);
// Collapse all signal
useEffect(() => {
if (collapseSignal > 0) {
setExpanded(false)
setExpanded(false);
}
}, [collapseSignal])
}, [collapseSignal]);
const loadChildren = useCallback(async () => {
if (loaded || !hasChildren) return
setLoading(true)
if (loaded || !hasChildren) return;
setLoading(true);
try {
const catParam = catalogType === 'bc' ? '&catalog=bc' : ''
const res = await fetch(`/api/admin/catalog/tree?parentId=${node.id}${catParam}`)
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
const res = await fetch(`/api/admin/catalog/tree?parentId=${node.id}${catParam}`);
if (res.ok) {
const data = await res.json()
setChildren(data.pages || [])
setLoaded(true)
const data = await res.json();
setChildren(data.pages || []);
setLoaded(true);
}
} catch (error) {
console.error('[CatalogTree] Failed to load children:', error)
console.error("[CatalogTree] Failed to load children:", error);
} finally {
setLoading(false)
setLoading(false);
}
}, [node.id, loaded, hasChildren, catalogType])
}, [node.id, loaded, hasChildren, catalogType]);
const handleToggle = () => {
if (!expanded && !loaded) {
loadChildren().then(() => setExpanded(true))
return
loadChildren().then(() => setExpanded(true));
return;
}
setExpanded(!expanded)
}
setExpanded(!expanded);
};
const isDisabled = node.enabled !== '1'
const isHidden = node.visible !== '1'
const isEmpty = node.itemCount === 0 && node.childCount === 0
const layoutColor = LAYOUT_COLORS[node.pageLayout] || 'bg-muted text-muted-foreground'
const italianName = translateCaption(node.caption)
const displayName = italianName ?? node.caption
const isDisabled = node.enabled !== "1";
const isHidden = node.visible !== "1";
const isEmpty = node.itemCount === 0 && node.childCount === 0;
const layoutColor = LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground";
const italianName = translateCaption(node.caption);
const displayName = italianName ?? node.caption;
return (
<div>
@@ -561,26 +558,26 @@ function TreeItem({
role="button"
tabIndex={0}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default',
isDisabled && 'opacity-40',
isEmpty && !isDisabled && 'opacity-60',
"flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-accent/60 group transition-colors cursor-default",
isDisabled && "opacity-40",
isEmpty && !isDisabled && "opacity-60",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onDoubleClick={() =>
router.push(
catalogType === 'bc'
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.key === "Enter") {
e.preventDefault();
router.push(
catalogType === 'bc'
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
)
);
}
}}
>
@@ -596,8 +593,8 @@ function TreeItem({
) : (
<ChevronRight
className={cn(
'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150',
expanded && 'rotate-90',
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-150",
expanded && "rotate-90",
)}
/>
)}
@@ -612,16 +609,16 @@ function TreeItem({
{/* Caption link */}
<Link
href={
catalogType === 'bc'
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`
}
className={cn(
'flex-1 min-w-0 hover:text-primary transition-colors',
isEmpty && !isDisabled ? 'text-muted-foreground italic' : '',
"flex-1 min-w-0 hover:text-primary transition-colors",
isEmpty && !isDisabled ? "text-muted-foreground italic" : "",
)}
>
<span className={cn('text-sm truncate block', !isEmpty && 'font-medium')}>
<span className={cn("text-sm truncate block", !isEmpty && "font-medium")}>
<HighlightText text={displayName} query={searchQuery} />
</span>
{italianName && (
@@ -637,25 +634,21 @@ function TreeItem({
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleEnabled(node)
e.stopPropagation();
onToggleEnabled(node);
}}
className={cn(
'flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity',
"flex items-center justify-center h-5 w-5 shrink-0 rounded opacity-0 group-hover:opacity-100 transition-opacity",
isDisabled
? 'hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]'
: 'hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]',
? "hover:bg-[var(--admin-success-subtle)] text-muted-foreground hover:text-[var(--admin-success)]"
: "hover:bg-[var(--admin-error-subtle)] text-muted-foreground hover:text-[var(--admin-error)]",
)}
>
{isDisabled ? (
<Power className="h-3 w-3" />
) : (
<PowerOff className="h-3 w-3" />
)}
{isDisabled ? <Power className="h-3 w-3" /> : <PowerOff className="h-3 w-3" />}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{isDisabled ? 'Enable' : 'Disable'}</p>
<p>{isDisabled ? "Enable" : "Disable"}</p>
</TooltipContent>
</Tooltip>
@@ -665,12 +658,12 @@ function TreeItem({
<button
type="button"
onClick={(e) => {
e.stopPropagation()
e.stopPropagation();
router.push(
catalogType === 'bc'
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog?newPageParent=${node.id}`,
)
);
}}
className="flex items-center justify-center h-5 w-5 shrink-0 rounded hover:bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
>
@@ -698,11 +691,11 @@ function TreeItem({
{/* Layout badge on hover */}
<Badge
className={cn(
'h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0',
"h-5 px-1.5 text-[9px] font-mono opacity-0 group-hover:opacity-100 transition-opacity border-0",
layoutColor,
)}
>
{node.pageLayout.replace('default_3x3', '3x3')}
{node.pageLayout.replace("default_3x3", "3x3")}
</Badge>
</div>
</div>
@@ -731,11 +724,11 @@ function TreeItem({
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Visible</td>
<td className="py-px">{node.visible === '1' ? 'Yes' : 'No'}</td>
<td className="py-px">{node.visible === "1" ? "Yes" : "No"}</td>
</tr>
<tr>
<td className="text-muted-foreground pr-3 py-px">Enabled</td>
<td className="py-px">{node.enabled === '1' ? 'Yes' : 'No'}</td>
<td className="py-px">{node.enabled === "1" ? "Yes" : "No"}</td>
</tr>
</tbody>
</table>
@@ -754,9 +747,7 @@ function TreeItem({
<ContextMenuItem
onClick={() =>
router.push(
catalogType === 'bc'
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog/${node.id}`,
catalogType === "bc" ? `/admin/catalog/builder-club/${node.id}` : `/admin/catalog/${node.id}`,
)
}
>
@@ -769,26 +760,18 @@ function TreeItem({
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onToggleEnabled(node)}>
{node.enabled === '1' ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
{node.enabled === '1' ? 'Disable' : 'Enable'}
{node.enabled === "1" ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
{node.enabled === "1" ? "Disable" : "Enable"}
</ContextMenuItem>
<ContextMenuItem onClick={() => onToggleVisible(node)}>
{node.visible === '1' ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{node.visible === '1' ? 'Hide' : 'Show'}
{node.visible === "1" ? <EyeOffIcon className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{node.visible === "1" ? "Hide" : "Show"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() =>
router.push(
catalogType === 'bc'
catalogType === "bc"
? `/admin/catalog/builder-club/${node.id}`
: `/admin/catalog?newPageParent=${node.id}`,
)
@@ -810,11 +793,9 @@ function TreeItem({
children.length > 0 &&
(() => {
const filteredChildren =
filterMode === 'all'
filterMode === "all"
? children
: children.filter((c) =>
filterMode === 'enabled' ? c.enabled === '1' : c.enabled !== '1',
)
: children.filter((c) => (filterMode === "enabled" ? c.enabled === "1" : c.enabled !== "1"));
return filteredChildren.length > 0 ? (
<div className="relative">
{/* Tree guide line */}
@@ -826,8 +807,8 @@ function TreeItem({
<TreeItem key={child.id} node={child} level={level + 1} />
))}
</div>
) : null
) : null;
})()}
</div>
)
);
}
@@ -1,63 +1,57 @@
'use client'
"use client";
import { AlertTriangle, CheckCircle2, Loader2, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
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 { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { AlertTriangle, CheckCircle2, Loader2, Upload } from "lucide-react";
import { useMemo, useState } from "react";
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 { 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[] = []
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)
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
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
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: false, error: "Invalid number" });
return;
}
out.push({
lineNumber: idx + 1,
@@ -67,29 +61,29 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
credits,
points,
pointsType,
})
})
return out
}, [input])
});
});
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)
const valid = parsed.filter((r) => r.ok);
if (valid.length === 0) {
toast.error('No valid rows to import')
return
toast.error("No valid rows to import");
return;
}
setImporting(true)
let success = 0
let failed = 0
setImporting(true);
let success = 0;
let failed = 0;
for (const row of valid) {
try {
const res = await createCatalogItem({
pageId,
itemIds: String(row.baseId!),
catalogName: '',
catalogName: "",
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
@@ -99,24 +93,24 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
})
if (res.ok) success++
else failed++
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
if (res.ok) success++;
else failed++;
} catch {
failed++
failed++;
}
}
setImporting(false)
setImporting(false);
if (success > 0) {
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ''}.`)
setInput('')
setOpen(false)
onImported?.()
toast.success(`Imported ${success} item(s)${failed > 0 ? `, ${failed} failed` : ""}.`);
setInput("");
setOpen(false);
onImported?.();
} else {
toast.error(`All ${failed} imports failed.`)
toast.error(`All ${failed} imports failed.`);
}
}
@@ -144,10 +138,9 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
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.
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>
@@ -179,14 +172,12 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
</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>
<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>
@@ -212,11 +203,11 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validCount} item{validCount !== 1 ? 's' : ''}
Import {validCount} item{validCount !== 1 ? "s" : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
);
}
@@ -1,108 +1,102 @@
'use client'
"use client";
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 { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
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 { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface CatalogImagePickerProps {
value: string
onChange: (name: string) => void
value: string;
onChange: (name: string) => void;
/** Filter: 'header' | 'teaser' | 'all' */
type?: 'header' | 'teaser' | 'all'
disabled?: boolean
label?: string
type?: "header" | "teaser" | "all";
disabled?: boolean;
label?: string;
}
const PAGE_SIZE = 80
const PAGE_SIZE = 80;
export function CatalogImagePicker({
value,
onChange,
type = 'all',
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])
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
const fetchImages = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
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)
});
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)
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])
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
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 300) {
fetchImages(offset, false)
fetchImages(offset, false);
}
}, [loading, hasMore, offset, fetchImages])
}, [loading, hasMore, offset, fetchImages]);
const handleSelect = (name: string) => {
onChange(name)
setOpen(false)
}
onChange(name);
setOpen(false);
};
const handleClear = () => {
onChange('')
setOpen(false)
}
onChange("");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -122,27 +116,20 @@ export function CatalogImagePicker({
)}
<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'}
{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'}
{type === "header"
? "Select Headline Image"
: type === "teaser"
? "Select Teaser Image"
: "Select Catalog Image"}
</DialogTitle>
</DialogHeader>
@@ -159,7 +146,7 @@ export function CatalogImagePicker({
{search && (
<button
type="button"
onClick={() => setSearch('')}
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" />
@@ -189,8 +176,8 @@ export function CatalogImagePicker({
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',
"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}
>
@@ -213,17 +200,17 @@ export function CatalogImagePicker({
</div>
</DialogContent>
</Dialog>
)
);
}
// ── Thumbnail with gif/png fallback ─────────────────────────────
function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number }) {
const [error, setError] = useState(0)
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])
useEffect(() => setError(0), [name]);
if (!name || error >= 2) {
return (
@@ -233,20 +220,19 @@ function CatalogImageThumb({ name, size = 60 }: { name: string; size?: number })
>
<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`
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 }}
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size * 0.75 }}
onError={() => setError((e) => e + 1)}
/>
)
);
}
+67 -80
View File
@@ -1,114 +1,103 @@
'use client'
"use client";
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 { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
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 { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface IconPickerProps {
value: number
onChange: (iconImage: number) => void
disabled?: boolean
value: number;
onChange: (iconImage: number) => void;
disabled?: boolean;
/** Optional trigger override. If not provided, a default button is rendered. */
children?: React.ReactNode
children?: React.ReactNode;
}
const PAGE_SIZE = 120
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)
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])
const t = setTimeout(() => setDebounced(search.trim()), 250);
return () => clearTimeout(t);
}, [search]);
const fetchIcons = useCallback(
async (nextOffset: number, replace: boolean) => {
setLoading(true)
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)
});
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)
console.error("[IconPicker]", err);
} finally {
setLoading(false)
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])
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
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) {
fetchIcons(offset, false)
fetchIcons(offset, false);
}
}, [loading, hasMore, offset, fetchIcons])
}, [loading, hasMore, offset, fetchIcons]);
const handleSelect = (id: number) => {
onChange(id)
setOpen(false)
}
onChange(id);
setOpen(false);
};
const handleClear = () => {
onChange(0)
setOpen(false)
}
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"
>
<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'}
{value > 0 ? `#${value}` : "None"}
</span>
</Button>
)}
@@ -131,7 +120,7 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
{search && (
<button
type="button"
onClick={() => setSearch('')}
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" />
@@ -162,8 +151,8 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
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',
"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}`}
>
@@ -182,20 +171,18 @@ export function IconPicker({ value, onChange, disabled, children }: IconPickerPr
</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)
const [error, setError] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => setError(false), [iconImage])
useEffect(() => setError(false), [iconImage]);
if (error || iconImage <= 0) {
return (
<Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />
)
return <Package className="shrink-0 text-muted-foreground" style={{ width: size, height: size }} />;
}
return (
@@ -205,8 +192,8 @@ function IconPreview({ iconImage, size = 24 }: { iconImage: number; size?: numbe
width={size}
height={size}
className="shrink-0 object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={() => setError(true)}
/>
)
);
}
@@ -1,18 +1,18 @@
'use client'
"use client";
import { Crown, Image as ImageIcon, Package, Sparkles } from 'lucide-react'
import type { CatalogItemData } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { getPointsLabel } from '@/app/admin/catalog/[id]/catalog-items-table/types'
import { Badge } from '@/components/ui/badge'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
import { cn } from '@/lib/utils'
import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import { getPointsLabel } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import { Badge } from "@/components/ui/badge";
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({
@@ -28,11 +28,11 @@ export function ItemsShopPreview({
<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-sky-50 to-sky-100 dark:from-slate-900 dark:to-slate-950 p-4">
@@ -40,13 +40,13 @@ export function ItemsShopPreview({
<span className="font-medium">Shop preview · {layout}</span>
<span>{items.length} items</span>
</div>
<div className={cn('grid gap-2', gridClass)}>
<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
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
@@ -55,9 +55,9 @@ export function ItemsShopPreview({
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',
"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}
>
@@ -67,7 +67,7 @@ export function ItemsShopPreview({
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ''}
classname={furni?.classname ?? ""}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
@@ -125,82 +125,71 @@ export function ItemsShopPreview({
</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'
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'
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
case "frontpage":
case "single_bundle":
case "room_bundle":
return 140;
case "spaces_new":
return 110;
default:
return 96
return 96;
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string
revision: number
size: number
}) {
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 }}
/>
)
<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
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = '0'
img.style.opacity = "0";
}
}
@@ -210,9 +199,9 @@ function ShopFurniIcon({
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: 'pixelated', maxWidth: size, maxHeight: size }}
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
)
);
}
@@ -1,85 +1,79 @@
'use client'
"use client";
import { Loader2, Package, Search, Zap } from 'lucide-react'
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 { Input } from '@/components/ui/input'
import { useServerAction } from '@/hooks/use-server-action'
import { getHabboCdnIconUrl, getLocalIconUrl } from '@/lib/furni/classname'
import { Loader2, Package, Search, Zap } from "lucide-react";
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 { 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)
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)
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)
}, [])
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 300)
return () => clearTimeout(t)
}, [query])
const t = setTimeout(() => setDebounced(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
if (open) setTimeout(() => inputRef.current?.focus(), 50);
}, [open]);
// Fetch results
useEffect(() => {
if (!open || debounced.length < 2) {
setResults([])
return
setResults([]);
return;
}
setLoading(true)
const ac = new AbortController()
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])
.finally(() => setLoading(false));
return () => ac.abort();
}, [debounced, open]);
const handleAdd = useCallback(
(base: FurniResult) => {
@@ -98,21 +92,21 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: '1',
clubOnly: '0',
extradata: '',
haveOffer: "1",
clubOnly: "0",
extradata: "",
}),
{
successMessage: `Added "${base.publicName || base.itemName}" to page.`,
errorMessage: 'Failed to add item.',
errorMessage: "Failed to add item.",
onSuccess: () => {
toast.success(`Added #${base.id}`)
toast.success(`Added #${base.id}`);
},
},
)
);
},
[pageId, run],
)
);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -120,9 +114,7 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
<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>
<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">
@@ -156,7 +148,7 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
) : (
<div className="divide-y">
{results.map((r) => {
const furni = furniRevisionMap[r.id]
const furni = furniRevisionMap[r.id];
return (
<button
key={r.id}
@@ -165,15 +157,10 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
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}
/>
<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>
)}
{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}
@@ -181,14 +168,14 @@ export function QuickAddFurni({ pageId, furniRevisionMap }: QuickAddFurniProps)
</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 }) {
@@ -197,17 +184,17 @@ function FurniThumb({ classname, revision }: { classname: string; revision: numb
<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)
const primary = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = 'cdn'
img.src = getHabboCdnIconUrl(classname, revision)
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = '0'
img.style.opacity = "0";
}
}
@@ -218,10 +205,10 @@ function FurniThumb({ classname, revision }: { classname: string; revision: numb
src={primary}
alt={classname}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: 'pixelated' }}
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
)
);
}
+37 -8
View File
@@ -1,15 +1,44 @@
'use client'
"use client";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ChartDataPoint { label: string; value: number }
interface ChartDataPoint {
label: string;
value: number;
}
export function DashboardChart({ title, data, description }: { title: string; data: ChartDataPoint[]; color?: string; description?: string }) {
const max = Math.max(1, ...data.map((point) => point.value))
export function DashboardChart({
title,
data,
description,
}: {
title: string;
data: ChartDataPoint[];
color?: string;
description?: string;
}) {
const max = Math.max(1, ...data.map((point) => point.value));
return (
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium">{title}</CardTitle>{description && <p className="text-xs text-muted-foreground">{description}</p>}</CardHeader>
<CardContent><div className="flex h-[200px] items-end gap-1" aria-label={title}>{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>
<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>
)
);
}
+111 -141
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
ArrowDown,
@@ -11,181 +11,164 @@ import {
Search,
SlidersHorizontal,
X,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useDebounce } from '@/hooks/use-debounce'
import { PER_PAGE_OPTIONS } from '@/lib/admin-helpers'
import type { DataTableColumn, PaginatedResult } from '@/types'
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useDebounce } from "@/hooks/use-debounce";
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types";
/**
* One-click filter preset. The chip applies/clears the listed query-string
* keys at once; the preset is "active" when every key matches the URL state.
*/
export interface DataTablePreset {
label: string
params: Record<string, string>
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...',
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> = {}
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 key = col.filterKey || `filter_${col.key}`;
const val = searchParams.get(key);
if (val) activeFilters[key] = val;
}
const activeFilterCount = Object.keys(activeFilters).length
const activeFilterCount = Object.keys(activeFilters).length;
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
if (value) params.set(key, value);
else params.delete(key);
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
router.push(`?${params.toString()}`);
});
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
isFirstRender.current = false;
return;
}
updateParams({ search: debouncedSearch, page: '1' })
}, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps
updateParams({ search: debouncedSearch, page: "1" });
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(value: string) {
setSearchValue(value)
setSearchValue(value);
}
function handleSort(key: string) {
const newOrder = currentSort === key && currentOrder === 'asc' ? 'desc' : 'asc'
updateParams({ sort: key, order: newOrder })
const newOrder = currentSort === key && currentOrder === "asc" ? "desc" : "asc";
updateParams({ sort: key, order: newOrder });
}
function handlePageChange(page: number) {
updateParams({ page: String(page) })
updateParams({ page: String(page) });
}
function handlePerPageChange(perPage: string) {
updateParams({ perPage, page: '1' })
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' })
const key = col.filterKey || `filter_${col.key}`;
updateParams({ [key]: value === "__all__" ? "" : value, page: "1" });
}
function clearAllFilters() {
const updates: Record<string, string> = { page: '1' }
const updates: Record<string, string> = { page: "1" };
for (const col of filterColumns) {
const key = col.filterKey || `filter_${col.key}`
updates[key] = ''
const key = col.filterKey || `filter_${col.key}`;
updates[key] = "";
}
updateParams(updates)
updateParams(updates);
}
function toggleSelectAll() {
if (selectedIds.size === data.rows.length) {
setSelectedIds(new Set())
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(data.rows.map((r) => r.id)))
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
})
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()
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" />
)
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)
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' }
const updates: Record<string, string> = { page: "1" };
if (isPresetActive(preset)) {
for (const k of Object.keys(preset.params)) updates[k] = ''
for (const k of Object.keys(preset.params)) updates[k] = "";
} else {
Object.assign(updates, preset.params)
Object.assign(updates, preset.params);
}
updateParams(updates)
updateParams(updates);
}
return (
@@ -194,7 +177,7 @@ export function DataTable<T extends { id: number | string }>({
{presets && presets.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{presets.map((preset) => {
const active = isPresetActive(preset)
const active = isPresetActive(preset);
return (
<button
key={preset.label}
@@ -202,14 +185,14 @@ export function DataTable<T extends { id: number | string }>({
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'
? "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>
)}
@@ -248,26 +231,18 @@ export function DataTable<T extends { id: number | string }>({
<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}
>
<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__'
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)}
>
<Select value={currentValue} onValueChange={(v) => handleFilterChange(col, v)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
@@ -281,7 +256,7 @@ export function DataTable<T extends { id: number | string }>({
</SelectContent>
</Select>
</div>
)
);
})}
</div>
</PopoverContent>
@@ -292,22 +267,22 @@ export function DataTable<T extends { id: number | string }>({
{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)
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__')}
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>
)}
@@ -324,19 +299,16 @@ export function DataTable<T extends { id: number | string }>({
<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"
>
<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
})
const next = new Set(prev);
if (checked) next.delete(col.key);
else next.add(col.key);
return next;
});
}}
/>
{col.label}
@@ -374,7 +346,7 @@ export function DataTable<T extends { id: number | string }>({
{bulkActions.map((action) => (
<Button
key={action.value}
variant={action.variant || 'default'}
variant={action.variant || "default"}
size="sm"
onClick={() => handleBulkAction(action.value)}
>
@@ -424,9 +396,7 @@ export function DataTable<T extends { id: number | string }>({
{data.rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={
columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)
}
colSpan={columns.filter((c) => !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
No results found.
@@ -436,7 +406,7 @@ export function DataTable<T extends { id: number | string }>({
data.rows.map((row) => (
<TableRow
key={row.id}
className={isPending ? 'opacity-50' : undefined}
className={isPending ? "opacity-50" : undefined}
data-selected={selectedIds.has(row.id) || undefined}
>
{bulkActions && (
@@ -453,7 +423,7 @@ export function DataTable<T extends { id: number | string }>({
<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] ?? '')}
: String((row as Record<string, unknown>)[col.key] ?? "")}
</TableCell>
))}
</TableRow>
@@ -479,26 +449,26 @@ export function DataTable<T extends { id: number | string }>({
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(data.lastPage, 7) }, (_, i) => {
let pageNum: number
let pageNum: number;
if (data.lastPage <= 7) {
pageNum = i + 1
pageNum = i + 1;
} else if (data.page <= 4) {
pageNum = i + 1
pageNum = i + 1;
} else if (data.page >= data.lastPage - 3) {
pageNum = data.lastPage - 6 + i
pageNum = data.lastPage - 6 + i;
} else {
pageNum = data.page - 3 + i
pageNum = data.page - 3 + i;
}
return (
<Button
key={pageNum}
variant={pageNum === data.page ? 'default' : 'outline'}
variant={pageNum === data.page ? "default" : "outline"}
size="icon"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
)
);
})}
<Button
variant="outline"
@@ -512,5 +482,5 @@ export function DataTable<T extends { id: number | string }>({
</div>
)}
</div>
)
);
}
+116 -124
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
ChevronLeft,
@@ -9,62 +9,56 @@ import {
RotateCcw,
Save,
Search,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { saveEmulatorSettings } from '@/actions/emulator'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useServerAction } from '@/hooks/use-server-action'
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { saveEmulatorSettings } from "@/actions/emulator";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
interface Setting {
key: string
value: string
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
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,
@@ -73,130 +67,130 @@ export function EmulatorSettingsForm({
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)
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 }
let dirty = false;
const next = { ...prev };
for (const s of settings) {
if (!(s.key in next)) {
next[s.key] = s.value
dirty = true
next[s.key] = s.value;
dirty = true;
}
}
return dirty ? next : prev
})
}, [settings])
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>()
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)
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])
.sort((a, b) => a.name.localeCompare(b.name));
}, [allGroups, settings]);
function toggleGroup(name: string) {
updateParams({
group: activeGroup === name ? '' : name,
page: '1',
})
group: activeGroup === name ? "" : name,
page: "1",
});
}
function getDisplayValue(setting: Setting): string {
return values[setting.key] ?? setting.value
return values[setting.key] ?? setting.value;
}
function updateValue(key: string, val: string) {
setValues((prev) => ({ ...prev, [key]: val }))
setValues((prev) => ({ ...prev, [key]: val }));
setChangedKeys((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (originalValues[key] !== undefined && val === originalValues[key]) {
next.delete(key)
next.delete(key);
} else {
next.add(key)
next.add(key);
}
return next
})
return next;
});
}
function revertChange(key: string) {
setValues((prev) => {
const next = { ...prev }
delete next[key]
return next
})
const next = { ...prev };
delete next[key];
return next;
});
setChangedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
const next = new Set(prev);
next.delete(key);
return next;
});
}
function updateParams(updates: Record<string, string>) {
const params = new URLSearchParams(searchParams.toString())
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
if (value) params.set(key, value);
else params.delete(key);
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
router.push(`?${params.toString()}`);
});
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
updateParams({ search: searchValue, page: '1' })
e.preventDefault();
updateParams({ search: searchValue, page: "1" });
}
function handleSaveAll() {
if (changedKeys.size === 0) {
toast.info('No changes to save.')
return
toast.info("No changes to save.");
return;
}
const changedSettings: Record<string, string> = {}
const changedSettings: Record<string, string> = {};
for (const key of changedKeys) {
changedSettings[key] = values[key]
changedSettings[key] = values[key];
}
const count = changedKeys.size
const count = changedKeys.size;
run(() => saveEmulatorSettings({ settings: changedSettings }), {
successMessage: `${count} setting(s) saved and synced with emulator.`,
onSuccess: () => {
setChangedKeys(new Set())
setValues({})
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()
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) })
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 (
@@ -249,11 +243,11 @@ export function EmulatorSettingsForm({
</button>
)}
{groupSummary.map(({ name, count }) => {
const isActive = activeGroup === name
const isActive = activeGroup === name;
const color = (GROUP_BORDER_COLORS[name] || GROUP_BORDER_COLORS.general).replace(
'border-l-',
'bg-',
)
"border-l-",
"bg-",
);
return (
<button
key={name}
@@ -261,8 +255,8 @@ export function EmulatorSettingsForm({
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'
? "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}
@@ -271,7 +265,7 @@ export function EmulatorSettingsForm({
{name}
<span className="text-muted-foreground">{count}</span>
</button>
)
);
})}
</div>
)}
@@ -279,20 +273,18 @@ export function EmulatorSettingsForm({
{/* 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>
<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)
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)]' : ''
isChanged ? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]" : ""
}`}
>
<code
@@ -305,7 +297,7 @@ export function EmulatorSettingsForm({
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 ? "border-[var(--admin-warning-border)]" : ""
}`}
/>
{isChanged && (
@@ -320,7 +312,7 @@ export function EmulatorSettingsForm({
</Button>
)}
</div>
)
);
})
)}
</div>
@@ -331,7 +323,7 @@ export function EmulatorSettingsForm({
<span>Per page</span>
<Select
value={String(pagination.perPage)}
onValueChange={(v) => updateParams({ perPage: v, page: '1' })}
onValueChange={(v) => updateParams({ perPage: v, page: "1" })}
>
<SelectTrigger size="sm" className="w-[80px]">
<SelectValue />
@@ -346,7 +338,7 @@ export function EmulatorSettingsForm({
</Select>
<span className="ml-2">
{pagination.total === 0
? '0 results'
? "0 results"
: `${(pagination.page - 1) * pagination.perPage + 1}–${Math.min(
pagination.page * pagination.perPage,
pagination.total,
@@ -359,7 +351,7 @@ export function EmulatorSettingsForm({
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: '1' })}
onClick={() => updateParams({ page: "1" })}
title="First page"
>
<ChevronsLeft className="h-4 w-4" />
@@ -381,7 +373,7 @@ export function EmulatorSettingsForm({
key={pagination.page}
onBlur={(e) => jumpToPage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToPage((e.target as HTMLInputElement).value)
if (e.key === "Enter") jumpToPage((e.target as HTMLInputElement).value);
}}
className="h-8 w-14 text-center text-xs"
/>
@@ -407,5 +399,5 @@ export function EmulatorSettingsForm({
</div>
</div>
</div>
)
);
}
+19 -23
View File
@@ -1,24 +1,24 @@
'use client'
"use client";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface PeakHoursData {
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
grid: number[][]
max: number
grid: number[][];
max: number;
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const HOURS = Array.from({ length: 24 }, (_, i) => i)
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 }) {
@@ -26,9 +26,7 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
<p className="text-xs text-muted-foreground">
User activity by day &amp; hour (last 7 days)
</p>
<p className="text-xs text-muted-foreground">User activity by day &amp; hour (last 7 days)</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
@@ -37,19 +35,17 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
<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}` : ''}
{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>
<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
const count = data.grid[dayIdx]?.[hour] ?? 0;
return (
<Tooltip key={hour}>
<TooltipTrigger asChild>
@@ -61,7 +57,7 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
{day} {hour}:00 — {count} users
</TooltipContent>
</Tooltip>
)
);
})}
</TooltipProvider>
</div>
@@ -80,5 +76,5 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
</div>
</CardContent>
</Card>
)
);
}
+36 -5
View File
@@ -1,10 +1,41 @@
'use client'
"use client";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface RevenueDataPoint { label: string; credits: number; points: number }
interface RevenueDataPoint {
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>
);
}
+19 -19
View File
@@ -1,15 +1,15 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Minus, TrendingDown, TrendingUp } from "lucide-react";
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
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
trend?: number;
}
export function StatsCard({ title, value, description, icon, href, trend }: StatsCardProps) {
@@ -17,8 +17,8 @@ export function StatsCard({ title, value, description, icon, href, trend }: Stat
<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'
: ''
? "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">
@@ -33,13 +33,13 @@ export function StatsCard({ title, value, description, icon, href, trend }: Stat
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
);
if (href) {
return <Link href={href}>{content}</Link>
return <Link href={href}>{content}</Link>;
}
return content
return content;
}
function TrendBadge({ value }: { value: number }) {
@@ -49,19 +49,19 @@ function TrendBadge({ value }: { value: number }) {
<Minus className="h-3 w-3" />
0%
</span>
)
);
}
const isUp = value > 0
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 ? "text-[var(--admin-success)]" : "text-[var(--admin-error)]"
}`}
>
{isUp ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{isUp ? '+' : ''}
{isUp ? "+" : ""}
{value}%
</span>
)
);
}
+21 -21
View File
@@ -1,44 +1,44 @@
'use client'
"use client";
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { toggleUserWatch } from '@/actions/watch'
import { Button } from '@/components/ui/button'
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { toggleUserWatch } from "@/actions/watch";
import { Button } from "@/components/ui/button";
interface UserWatchToggleProps {
targetUserId: number
username: string
initialWatching: boolean
targetUserId: number;
username: string;
initialWatching: boolean;
}
export function UserWatchToggle({ targetUserId, username, initialWatching }: UserWatchToggleProps) {
const [watching, setWatching] = useState(initialWatching)
const [busy, setBusy] = useState(false)
const [watching, setWatching] = useState(initialWatching);
const [busy, setBusy] = useState(false);
async function handleToggle() {
setBusy(true)
setBusy(true);
try {
const result = await toggleUserWatch({ targetUserId })
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}`)
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)
toast.error(result.error);
}
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<Button
variant={watching ? 'default' : 'outline'}
variant={watching ? "default" : "outline"}
size="sm"
disabled={busy}
onClick={handleToggle}
title={watching ? 'Stop watching this user' : 'Watch this user'}
title={watching ? "Stop watching this user" : "Watch this user"}
>
{watching ? (
<>
@@ -50,5 +50,5 @@ export function UserWatchToggle({ targetUserId, username, initialWatching }: Use
</>
)}
</Button>
)
);
}
+4 -1
View File
@@ -120,7 +120,10 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
/>
</fieldset>
</div>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
<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>
+31 -5
View File
@@ -36,17 +36,43 @@ export function CmsInfoPopup() {
</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>
<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>
<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>)}
{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>)}
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</div>
</section>
</div>
+4 -1
View File
@@ -156,7 +156,10 @@ export default async function GuestView() {
</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))" }}>
<p
className="py-8 text-center"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{t("noArticles")}
</p>
) : (
+4 -1
View File
@@ -185,7 +185,10 @@ export default async function UserView({ userId, username, look }: UserViewProps
<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))" }}>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
+12 -19
View File
@@ -1,27 +1,20 @@
import { type AvatarOptions, getAvatarUrl } from '@/lib/imager'
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) {
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')
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
@@ -35,5 +28,5 @@ export function AvatarImage({
style={{ maxWidth: width, maxHeight: height }}
className={`image-pixelated object-contain ${className}`}
/>
)
);
}
+28 -12
View File
@@ -14,17 +14,33 @@ describe("themePaletteCss", () => {
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);
"--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);
});
});
+30 -33
View File
@@ -89,37 +89,35 @@ export async function ThemeVars() {
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 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 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"),
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"),
});
@@ -129,10 +127,9 @@ export async function ThemeVars() {
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),
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);
+15 -4
View File
@@ -9,8 +9,15 @@ function Currency({ icon, amount, label }: { icon: string; amount: number; label
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)))" }}>
<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>
@@ -62,7 +69,9 @@ export async function TopHeader() {
<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)))" }}
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
Administration
</summary>
@@ -101,7 +110,9 @@ export async function TopHeader() {
/>
<span
className="-ml-2 font-semibold"
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))" }}
style={{
color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))",
}}
>
{session.user.name}
</span>
+18 -19
View File
@@ -1,37 +1,36 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
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',
"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',
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',
"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',
variant: "default",
},
},
)
);
function Badge({
className,
variant = 'default',
variant = "default",
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span'
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
@@ -40,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+27 -27
View File
@@ -1,52 +1,52 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
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',
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',
"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',
"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',
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',
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',
variant: "default",
size: "default",
},
},
)
);
function Button({
className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<'button'> &
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button'
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -56,7 +56,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+24 -34
View File
@@ -1,75 +1,65 @@
import type * as React from 'react'
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<'div'>) {
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',
"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'>) {
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',
"@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'>) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
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-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
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)}
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} />
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
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)}
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 };
+8 -8
View File
@@ -1,17 +1,17 @@
'use client'
"use client";
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import type * as React from 'react'
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
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',
"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}
@@ -23,7 +23,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
+33 -57
View File
@@ -1,42 +1,36 @@
'use client'
"use client";
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 { cn } from '@/lib/utils'
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 { 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',
"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...',
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}>
@@ -44,84 +38,66 @@ function CommandDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<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>) {
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',
"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)}
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}
/>
)
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
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',
"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>) {
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)}
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
@@ -134,17 +110,17 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
)}
{...props}
/>
)
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
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)}
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
);
}
export {
@@ -157,4 +133,4 @@ export {
CommandList,
CommandSeparator,
CommandShortcut,
}
};
+22 -27
View File
@@ -1,18 +1,16 @@
'use client'
"use client";
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'
import type * as React from 'react'
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
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({
@@ -24,23 +22,23 @@ function ContextMenuContent({
<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',
"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',
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
@@ -53,7 +51,7 @@ function ContextMenuItem({
)}
{...props}
/>
)
);
}
function ContextMenuSeparator({
@@ -63,10 +61,10 @@ function ContextMenuSeparator({
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function ContextMenuLabel({
@@ -74,29 +72,26 @@ function ContextMenuLabel({
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,
)}
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'>) {
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)}
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
);
}
export {
@@ -107,4 +102,4 @@ export {
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
}
};
+28 -31
View File
@@ -1,41 +1,38 @@
'use client'
"use client";
import { XIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
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} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
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',
"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({
@@ -44,7 +41,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -52,7 +49,7 @@ function DialogContent({
<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',
"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}
@@ -69,17 +66,17 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
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)}
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -87,13 +84,13 @@ function DialogFooter({
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
@@ -103,17 +100,17 @@ function DialogFooter({
</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)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -123,10 +120,10 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -140,4 +137,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+35 -41
View File
@@ -1,25 +1,21 @@
'use client'
"use client";
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import type * as React from 'react'
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
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({
@@ -33,27 +29,27 @@ function DropdownMenuContent({
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',
"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} />
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -66,7 +62,7 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -92,13 +88,11 @@ function DropdownMenuCheckboxItem({
</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({
@@ -122,7 +116,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -130,16 +124,16 @@ function DropdownMenuLabel({
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)}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -149,24 +143,24 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
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)}
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} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -175,7 +169,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -190,7 +184,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -201,12 +195,12 @@ function DropdownMenuSubContent({
<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',
"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 {
@@ -225,4 +219,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
};
+8 -8
View File
@@ -1,21 +1,21 @@
import type * as React from 'react'
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
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',
"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 }
export { Input };
+7 -7
View File
@@ -1,21 +1,21 @@
'use client'
"use client";
import { Label as LabelPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
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',
"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 }
export { Label };
+18 -28
View File
@@ -1,21 +1,21 @@
'use client'
"use client";
import { Popover as PopoverPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
@@ -26,41 +26,31 @@ function PopoverContent({
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',
"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} />
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn('flex flex-col gap-1 text-sm', className)}
{...props}
/>
)
<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} />
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
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 {
@@ -71,4 +61,4 @@ export {
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
};
+32 -39
View File
@@ -1,30 +1,30 @@
'use client'
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { Select as SelectPrimitive } from 'radix-ui'
import type * as React from 'react'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -41,14 +41,14 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -56,9 +56,9 @@ function SelectContent({
<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',
"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}
@@ -68,9 +68,9 @@ function SelectContent({
<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',
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
@@ -78,24 +78,20 @@ function SelectContent({
<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)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
@@ -115,20 +111,17 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
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)}
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -138,12 +131,12 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -153,12 +146,12 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -172,4 +165,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+5 -9
View File
@@ -1,13 +1,9 @@
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
<div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />
);
}
export { Skeleton }
export { Skeleton };
+10 -10
View File
@@ -1,23 +1,23 @@
'use client'
"use client";
import { Switch as SwitchPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Switch({
className,
size = 'default',
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',
"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}
@@ -25,11 +25,11 @@ function Switch({
<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',
"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 }
export { Switch };
+26 -39
View File
@@ -1,92 +1,79 @@
'use client'
"use client";
import type * as React from 'react'
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<'table'>) {
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}
/>
<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} />
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
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}
/>
)
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
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)}
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
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,
)}
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
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 whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
)
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
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 };
+22 -22
View File
@@ -1,14 +1,14 @@
'use client'
"use client";
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = 'horizontal',
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
@@ -16,30 +16,30 @@ function Tabs({
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
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',
"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',
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function TabsList({
className,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
@@ -49,7 +49,7 @@ function TabsList({
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
@@ -58,24 +58,24 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
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',
"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)}
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
);
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };
+6 -6
View File
@@ -1,18 +1,18 @@
import type * as React from 'react'
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
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',
"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 }
export { Textarea };
+10 -16
View File
@@ -1,29 +1,23 @@
'use client'
"use client";
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import type * as React from 'react'
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function TooltipProvider({
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} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -38,7 +32,7 @@ function TooltipContent({
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',
"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}
@@ -47,7 +41,7 @@ function TooltipContent({
<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 }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };