diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index 6d714e5f..dfff3f5e 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -28,6 +28,7 @@ export async function updateBcPage({ export async function deleteBcItem({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItemsBc.delete({ where: { id } }); + await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "bc_item_delete", @@ -36,6 +37,7 @@ export async function deleteBcItem({ id }: { id: number }) { targetId: id, }); revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; } export async function updateBcItem({ @@ -50,6 +52,7 @@ export async function updateBcItem({ }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); await prisma.catalogItemsBc.update({ where: { id }, data: data as any }); + await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "bc_item_update", @@ -58,6 +61,7 @@ export async function updateBcItem({ targetId: id, }); revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; } export async function createBcItem({ @@ -74,6 +78,7 @@ export async function createBcItem({ const created = await prisma.catalogItemsBc.create({ data: { pageId, ...data }, }); + await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "bc_item_create", @@ -82,6 +87,7 @@ export async function createBcItem({ targetId: created.id, }); revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: { id: created.id } }; } export async function toggleBcPage({ @@ -96,12 +102,14 @@ export async function toggleBcPage({ where: { id }, select: { enabled: true, visible: true }, }); - if (!page) return; + if (!page) return { ok: false as const, error: "Page not found" }; await prisma.catalogPagesBc.update({ where: { id }, data: { [field]: page[field] === "1" ? "0" : "1" }, }); + await rcon.updateCatalog(); revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; } export async function createBcPage(input: { @@ -124,6 +132,7 @@ export async function createBcPage(input: { pageTeaser: "", }, }); + await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "bc_page_create", diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 0420d85d..75410a1f 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -118,45 +118,93 @@ export async function updateCatalogItem({ export async function translateCatalogItems({ items, }: { + /** `id` is items_base.id (not catalog_items.id) */ items: Array<{ id: number; publicName: string; description: string }>; }) { await requirePermission(PERMS.CATALOG_EDIT); + const { invalidateFurniDataCache } = await import( + "@/lib/services/catalog-items-loader" + ); + const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); + let namesUpdated = 0; let descriptionsUpdated = 0; - let furniDataUpdated = 0; - const _furniDataInserted = 0; + const furniPatches: Array<{ + classname: string; + itemType: string; + name?: string; + description?: string; + spriteId?: number; + createIfMissing?: boolean; + }> = []; for (const item of items) { - const existing = await prisma.catalogItems.findUnique({ + const base = await prisma.itemsBase.findUnique({ where: { id: item.id }, - select: { catalogName: true }, + select: { + id: true, + publicName: true, + itemName: true, + type: true, + spriteId: true, + }, }); - if (!existing) continue; + if (!base) continue; - if (item.publicName && item.publicName !== existing.catalogName) { - await prisma.catalogItems.update({ - where: { id: item.id }, - data: { catalogName: item.publicName }, + const nextName = item.publicName?.trim() ?? ""; + const nextDesc = item.description ?? ""; + const nameChanged = nextName !== "" && nextName !== (base.publicName ?? ""); + + if (nameChanged) { + await prisma.itemsBase.update({ + where: { id: base.id }, + data: { publicName: nextName }, }); + // Sync catalogName for catalog rows that reference this base item + const idStr = String(base.id); + const related = await prisma.catalogItems.findMany({ + where: { + OR: [ + { itemIds: idStr }, + { itemIds: { startsWith: `${idStr};` } }, + { itemIds: { contains: `;${idStr};` } }, + { itemIds: { endsWith: `;${idStr}` } }, + ], + }, + select: { id: true, catalogName: true }, + }); + for (const row of related) { + if (row.catalogName !== nextName) { + await prisma.catalogItems.update({ + where: { id: row.id }, + data: { catalogName: nextName }, + }); + } + } namesUpdated++; } - if (item.description) { - const baseItem = await prisma.itemsBase.findFirst({ - where: { itemName: existing.catalogName }, - select: { id: true, publicName: true }, + if (nextDesc !== "" || nameChanged) { + descriptionsUpdated += nextDesc !== "" ? 1 : 0; + furniPatches.push({ + classname: base.itemName, + itemType: base.type || "s", + name: nextName || base.publicName || base.itemName, + description: nextDesc, + spriteId: base.spriteId, + createIfMissing: true, }); - if (baseItem) { - await prisma.itemsBase.update({ - where: { id: baseItem.id }, - data: { publicName: item.publicName || baseItem.publicName }, - }); - furniDataUpdated++; - } - descriptionsUpdated++; } } + const furniResult = + furniPatches.length > 0 + ? await patchFurniEntryNames(furniPatches) + : { updated: 0, inserted: 0 }; + if (furniResult.updated > 0 || furniResult.inserted > 0) { + invalidateFurniDataCache(); + } + await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { @@ -164,8 +212,8 @@ export async function translateCatalogItems({ data: { namesUpdated, descriptionsUpdated, - furniDataUpdated, - furniDataInserted: 0, + furniDataUpdated: furniResult.updated, + furniDataInserted: furniResult.inserted, updated: items.length, }, }; diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index e2a20f35..25259814 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -5,6 +5,7 @@ import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import type { ActionResult } from "@/lib/safe-action-shared"; +import { deletePage, movePage } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -28,7 +29,7 @@ export async function updateCatalogPage({ export async function deleteCatalogPage({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogPages.delete({ where: { id } }); + await deletePage(id, "reparent"); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, @@ -60,6 +61,7 @@ export async function toggleCatalogPage({ where: { id }, data: { [field]: current === "1" ? "0" : "1" }, }); + await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; } @@ -95,6 +97,7 @@ export async function createCatalogPage(input: { includes: "", }, }); + await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_page_create", @@ -112,14 +115,19 @@ export async function reorderTreePage(input: { newOrderNum: number; }) { await requirePermission(PERMS.CATALOG_EDIT); + if (input.newParentId !== undefined) { + try { + await movePage(input.pageId, input.newParentId); + } catch (err) { + return { + ok: false as const, + error: err instanceof Error ? err.message : "Invalid move", + }; + } + } await prisma.catalogPages.update({ where: { id: input.pageId }, - data: { - orderNum: input.newOrderNum, - ...(input.newParentId === undefined - ? {} - : { parentId: input.newParentId }), - }, + data: { orderNum: input.newOrderNum }, }); await rcon.updateCatalog(); revalidatePath("/admin/catalog"); @@ -131,31 +139,7 @@ export async function deleteTreePage(input: { mode: "reparent" | "cascade"; }) { await requirePermission(PERMS.CATALOG_EDIT); - if (input.mode === "cascade") { - const children = await prisma.catalogPages.findMany({ - where: { parentId: input.pageId }, - select: { id: true }, - }); - const pageIds = [input.pageId, ...children.map((child) => child.id)]; - await prisma.$transaction([ - prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }), - prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }), - ]); - } else { - const page = await prisma.catalogPages.findUnique({ - where: { id: input.pageId }, - select: { parentId: true }, - }); - if (!page) return { ok: false as const, error: "Catalog page not found" }; - await prisma.$transaction([ - prisma.catalogPages.updateMany({ - where: { parentId: input.pageId }, - data: { parentId: page.parentId }, - }), - prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }), - prisma.catalogPages.delete({ where: { id: input.pageId } }), - ]); - } + await deletePage(input.pageId, input.mode); await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; diff --git a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx index 2853a204..13d44e47 100644 --- a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx +++ b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx @@ -47,6 +47,7 @@ import { import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items"; import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview"; import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { CurrencyIcon, currencyKindFromPointsType, @@ -60,7 +61,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -158,6 +158,7 @@ export function CatalogItemsTable({ }: CatalogItemsTableProps) { const router = useRouter(); const { run, isPending } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [addOpen, setAddOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [editingBase, setEditingBase] = useState(null); @@ -378,6 +379,7 @@ export function CatalogItemsTable({ setSavingItemId(null); router.refresh(); }, + onError: () => setSavingItemId(null), }, ); } @@ -436,7 +438,12 @@ export function CatalogItemsTable({ // ── Bulk delete selected ───────────────────────────────────────── async function handleBulkDelete() { if (selected.size === 0) return; - if (!confirm(`Delete ${selected.size} selected item(s)?`)) return; + const ok = await confirm({ + title: "Delete items", + description: `Delete ${selected.size} selected item(s)?`, + confirmLabel: "Delete", + }); + if (!ok) return; run(() => deleteCatalogItems({ ids: [...selected] }), { successMessage: `Deleted ${selected.size} item(s).`, @@ -584,7 +591,12 @@ export function CatalogItemsTable({ } async function handleDeleteItem(id: number) { - if (!confirm("Delete this catalog item?")) return; + const ok = await confirm({ + title: "Delete item", + description: "Delete this catalog item?", + confirmLabel: "Delete", + }); + if (!ok) return; run(() => deleteCatalogItems({ ids: [id] }), { successMessage: "Item deleted.", errorMessage: "Failed to delete item.", @@ -867,6 +879,8 @@ export function CatalogItemsTable({ } return ( + <> + {confirmDialog}
@@ -1009,12 +1023,10 @@ export function CatalogItemsTable({ onImported={() => router.refresh()} /> - - - + Add Catalog Item @@ -2302,5 +2314,6 @@ export function CatalogItemsTable({ + ); } diff --git a/src/app/admin/catalog/[id]/catalog-page-form.tsx b/src/app/admin/catalog/[id]/catalog-page-form.tsx index e4f402ec..06bb7b47 100644 --- a/src/app/admin/catalog/[id]/catalog-page-form.tsx +++ b/src/app/admin/catalog/[id]/catalog-page-form.tsx @@ -18,6 +18,7 @@ import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog"; import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker"; import { IconPicker } from "@/components/admin/catalog/icon-picker"; import { ImagePreview } from "@/components/admin/catalog-manager/image-preview"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -90,6 +91,7 @@ export function CatalogPageForm({ allPages, }: CatalogPageFormProps) { const { isPending, run } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const formRef = useRef(null); const [form, setForm] = useState({ @@ -140,13 +142,18 @@ export function CatalogPageForm({ }); } - function handleDelete() { + async function handleDelete() { const childCount = childPages.length; - const msg = + const description = childCount > 0 - ? `Delete this catalog page? Its ${childCount} child page(s) will be moved to root. All items in this page will be deleted.` + ? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.` : "Delete this catalog page and all its items?"; - if (!confirm(msg)) return; + const ok = await confirm({ + title: "Delete catalog page", + description, + confirmLabel: "Delete", + }); + if (!ok) return; run(() => deleteCatalogPage({ id: catalogPage.id }), { successMessage: "Page deleted.", redirectTo: "/admin/catalog", @@ -157,7 +164,9 @@ export function CatalogPageForm({ LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground"; return ( -
+ <> + {confirmDialog} +
{/* ── Left column (2/3): tabs ─────────────────────────── */}
@@ -495,6 +504,7 @@ export function CatalogPageForm({
+ ); } diff --git a/src/app/admin/catalog/builder-club/bc-manager.tsx b/src/app/admin/catalog/builder-club/bc-manager.tsx index e4c7712c..054083d5 100644 --- a/src/app/admin/catalog/builder-club/bc-manager.tsx +++ b/src/app/admin/catalog/builder-club/bc-manager.tsx @@ -41,6 +41,7 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; // ── Types ──────────────────────────────────────────────────────────── @@ -82,6 +83,7 @@ interface BcPageDetailProps { export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) { const { isPending, run } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const formRef = useRef(null); const [editingItem, setEditingItem] = useState(null); const [addingItem, setAddingItem] = useState(false); @@ -130,6 +132,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) { return (
+ {confirmDialog} {/* Header */}
{ - if ( - !confirm( - `Delete item "${item.catalogName}"?`, - ) - ) - return; + onClick={async () => { + const ok = await confirm({ + title: "Delete BC item", + description: `Delete item "${item.catalogName}"?`, + confirmLabel: "Delete", + }); + if (!ok) return; run(() => deleteBcItem({ id: item.id }), { successMessage: "Item deleted.", }); diff --git a/src/app/admin/catalog/catalog-actions.tsx b/src/app/admin/catalog/catalog-actions.tsx index bbb65d51..7b128e6c 100644 --- a/src/app/admin/catalog/catalog-actions.tsx +++ b/src/app/admin/catalog/catalog-actions.tsx @@ -11,7 +11,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -72,12 +71,10 @@ export function CatalogActions({ return ( - - - + diff --git a/src/app/api/admin/catalog/suggest/route.ts b/src/app/api/admin/catalog/suggest/route.ts index bf4dc547..13a4e37c 100644 --- a/src/app/api/admin/catalog/suggest/route.ts +++ b/src/app/api/admin/catalog/suggest/route.ts @@ -1,7 +1,7 @@ import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; -import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache"; +import { batchLookupByClassnames } from "@/lib/services/habbofurni"; export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, @@ -13,19 +13,16 @@ export const GET = withAdmin( .filter(Boolean) .slice(0, 100); + const found = await batchLookupByClassnames(classnames); const suggestions: Record = {}; - await Promise.all( - classnames.map(async (classname) => { - const entry = await lookupHabboIt(classname); - if (!entry) return; - suggestions[classname] = { - name: entry.name ?? "", - description: entry.description ?? "", - }; - }), - ); + for (const [classname, entry] of found) { + suggestions[classname] = { + name: entry.name ?? "", + description: entry.description ?? "", + }; + } return apiOk({ suggestions }); }, diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index e1fed99d..1c8b1a06 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,5 +1,3 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; @@ -9,6 +7,7 @@ import { getAncestors, getTreeFlat, } from "@/lib/services/catalog-tree"; +import { rcon } from "@/lib/services/rcon"; import type { TreeNode } from "@/types/catalog"; type PageRow = { @@ -262,6 +261,7 @@ export const PATCH = withAdmin( where: { id: pageId }, data: { [field]: current === "1" ? "0" : "1" }, }); + await rcon.updateCatalog(); return apiOk({}); } const page = await prisma.catalogPages.findUnique({ @@ -274,6 +274,7 @@ export const PATCH = withAdmin( where: { id: pageId }, data: { [field]: current === "1" ? "0" : "1" }, }); + await rcon.updateCatalog(); return apiOk({}); }, ); @@ -304,21 +305,34 @@ export const DELETE = withAdmin( prisma.catalogPagesBc.delete({ where: { id: pageId } }), ]); } else { - const children = await prisma.catalogPagesBc.findMany({ - where: { parentId: pageId }, - select: { id: true }, - }); - const ids = [pageId, ...children.map((c) => c.id)]; + // Deep cascade: collect all descendants + const toDelete: number[] = [pageId]; + const queue: number[] = [pageId]; + while (queue.length > 0) { + const children = await prisma.catalogPagesBc.findMany({ + where: { parentId: { in: queue } }, + select: { id: true }, + }); + queue.length = 0; + for (const child of children) { + toDelete.push(child.id); + queue.push(child.id); + } + } await prisma.$transaction([ prisma.catalogItemsBc.deleteMany({ - where: { pageId: { in: ids } }, + where: { pageId: { in: toDelete } }, + }), + prisma.catalogPagesBc.deleteMany({ + where: { id: { in: toDelete } }, }), - prisma.catalogPagesBc.deleteMany({ where: { id: { in: ids } } }), ]); } + await rcon.updateCatalog(); return apiOk({}); } await deletePage(pageId, mode); + await rcon.updateCatalog(); return apiOk({}); }, ); diff --git a/src/app/api/admin/furni/search/route.ts b/src/app/api/admin/furni/search/route.ts new file mode 100644 index 00000000..bc3e2c2d --- /dev/null +++ b/src/app/api/admin/furni/search/route.ts @@ -0,0 +1,59 @@ +import { withAdmin } from "@/lib/api-handler"; +import { apiOk } from "@/lib/api-response"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +/** + * Search items_base for Quick Add / catalog tooling. + * GET ?q=…&limit=30 + */ +export const GET = withAdmin( + { permission: PERMS.CATALOG_VIEW }, + async (request) => { + const q = (request.nextUrl.searchParams.get("q") || "").trim(); + const limit = Math.min( + Math.max( + Number.parseInt(request.nextUrl.searchParams.get("limit") || "30", 10) || + 30, + 1, + ), + 50, + ); + + if (q.length < 2) { + return apiOk({ results: [] }); + } + + const idExact = Number.parseInt(q, 10); + const rows = await prisma.itemsBase.findMany({ + where: { + OR: [ + { itemName: { contains: q } }, + { publicName: { contains: q } }, + ...(Number.isFinite(idExact) && String(idExact) === q + ? [{ id: idExact }] + : []), + ], + }, + select: { + id: true, + itemName: true, + publicName: true, + type: true, + spriteId: true, + }, + take: limit, + orderBy: { id: "asc" }, + }); + + return apiOk({ + results: rows.map((r) => ({ + id: r.id, + itemName: r.itemName ?? "", + publicName: r.publicName ?? "", + type: r.type ?? "s", + spriteId: r.spriteId ?? 0, + })), + }); + }, +); diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 1bc13cce..bdd32757 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -1,16 +1,20 @@ "use client"; -import { LayoutGrid } from "lucide-react"; +import { LayoutGrid, Plus } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { createCatalogPage } from "@/actions/catalog"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { RootTab } from "@/types/catalog"; import { BreadcrumbBar } from "./breadcrumb-bar"; @@ -47,15 +51,16 @@ export function CatalogManagerDialog() { function CatalogManagerInner({ open, - // biome-ignore lint/correctness/noUnusedFunctionParameters: part of the dialog public API - onOpenChange, }: { open: boolean; onOpenChange: (v: boolean) => void; }) { const { state, dispatch } = useCatalogManager(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); + const [createOpen, setCreateOpen] = useState(false); + const [newRootName, setNewRootName] = useState(""); + const [creating, setCreating] = useState(false); - // Load root pages when dialog opens const loadRootPages = useCallback(async () => { dispatch({ type: "SET_LOADING_ROOT", loading: true }); try { @@ -73,7 +78,6 @@ function CatalogManagerInner({ ); 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)) @@ -86,53 +90,65 @@ function CatalogManagerInner({ } }, [dispatch, state.activeTabId]); - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code + // biome-ignore lint/correctness/useExhaustiveDependencies: load once when dialog opens useEffect(() => { if (open) loadRootPages(); - }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + }, [open]); - // Handle dialog close: preserve tree state, clear selection useEffect(() => { if (!open) dispatch({ type: "ON_DIALOG_CLOSE" }); }, [open, dispatch]); - const handleTabClick = (id: number) => { - if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) - return; + async function confirmDiscardIfDirty(): Promise { + if (!state.editorDirty) return true; + return confirm({ + title: "Unsaved changes", + description: "You have unsaved changes. Discard them?", + confirmLabel: "Discard", + cancelLabel: "Keep editing", + }); + } + + const handleTabClick = async (id: number) => { + if (!(await confirmDiscardIfDirty())) 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 = newRootName.trim(); + if (!caption) return; + setCreating(true); try { const result = await createCatalogPage({ parentId: -1, - caption: caption.trim(), + caption, }); if (!result.ok) throw new Error(result.error); + setCreateOpen(false); + setNewRootName(""); await loadRootPages(); toast.success("Root page created"); } catch { toast.error("Failed to create root page"); + } finally { + setCreating(false); } }; - const handleSelectPage = (id: number) => { - if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) - return; + const handleSelectPage = async (id: number) => { + if (!(await confirmDiscardIfDirty())) 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 + const handleEditTab = async (id: number) => { if (state.activeTabId !== id) { + if (!(await confirmDiscardIfDirty())) return; dispatch({ type: "SET_ACTIVE_TAB", id }); dispatch({ type: "CLEAR_TREE" }); - } - if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) + } else if (!(await confirmDiscardIfDirty())) { return; + } dispatch({ type: "SELECT_PAGE", id }); }; @@ -140,7 +156,6 @@ function CatalogManagerInner({ dispatch({ type: "UPDATE_NODE", update: { ...updated } }); dispatch({ type: "SET_DIRTY", dirty: false }); - // Update root tabs if root page const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id); if (rootIdx !== -1) { const pages = [...state.rootPages]; @@ -155,7 +170,7 @@ function CatalogManagerInner({ return ( - {/* Header */} + {confirmDialog} @@ -163,20 +178,17 @@ function CatalogManagerInner({ - {/* Breadcrumb */} - {/* Root tabs */} setCreateOpen(true)} loading={state.loadingRoot} /> - {/* Main content: tree + editor */}
- {/* Stats bar */} + + + + + New root page + +
+ + setNewRootName(e.target.value)} + placeholder="e.g. Furniture" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateRoot(); + }} + /> +
+ + + + +
+
); } diff --git a/src/components/admin/catalog-manager/root-tabs.tsx b/src/components/admin/catalog-manager/root-tabs.tsx index 8ddb1df1..32dd6142 100644 --- a/src/components/admin/catalog-manager/root-tabs.tsx +++ b/src/components/admin/catalog-manager/root-tabs.tsx @@ -25,6 +25,7 @@ import { reorderTreePage, toggleCatalogPage, } from "@/actions/catalog"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { CatalogIcon } from "@/components/admin/catalog-tree"; import { Button } from "@/components/ui/button"; import { @@ -48,8 +49,8 @@ import type { RootTab } from "@/types/catalog"; interface RootTabsProps { tabs: RootTab[]; activeTabId: number | null; - onTabClick: (id: number) => void; - onEditTab: (id: number) => void; + onTabClick: (id: number) => void | Promise; + onEditTab: (id: number) => void | Promise; onCreateRoot: () => void; loading?: boolean; } @@ -62,6 +63,7 @@ export function RootTabs({ onCreateRoot, loading, }: RootTabsProps) { + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [activeDragId, setActiveDragId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -112,12 +114,12 @@ export function RootTabs({ }; const handleDelete = async (tab: RootTab) => { - if ( - !confirm( - `Delete root page "${tab.caption}"? Children will be reparented.`, - ) - ) - return; + const ok = await confirm({ + title: "Delete root page", + description: `Delete root page "${tab.caption}"? Children will be reparented.`, + confirmLabel: "Delete", + }); + if (!ok) return; try { await deleteTreePage({ pageId: tab.id, mode: "reparent" }); toast.success(`"${tab.caption}" deleted`); @@ -132,6 +134,7 @@ export function RootTabs({ return (
+ {confirmDialog} {loading && ( )} diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index f8550b8c..c569a415 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -40,9 +40,18 @@ import { toggleCatalogPage, updateCatalogPage, } from "@/actions/catalog"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, @@ -66,7 +75,7 @@ import type { TreeNodeData, TreeNodeUpdate } from "@/types/catalog"; interface SortableTreeProps { activeTabId: number | null; selectedPageId: number | null; - onSelectPage: (id: number) => void; + onSelectPage: (id: number) => void | Promise; onTreeMutation: () => void; nodeUpdate?: TreeNodeUpdate | null; } @@ -83,6 +92,7 @@ export function SortableTree({ nodeUpdate, }: SortableTreeProps) { const { state, dispatch } = useCatalogManager(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { nodes, childrenMap, @@ -97,6 +107,9 @@ export function SortableTree({ const [searchResults, setSearchResults] = useState( null, ); + const [subpageParentId, setSubpageParentId] = useState(null); + const [subpageName, setSubpageName] = useState(""); + const [creatingSubpage, setCreatingSubpage] = useState(false); const [searching, setSearching] = useState(false); const searchTimeout = useRef | null>(null); const [activeId, setActiveId] = useState(null); @@ -412,22 +425,32 @@ export function SortableTree({ }; // ── Actions ───────────────────────────────────────────────── - const handleAddSubpage = async (parentId: number) => { - const caption = prompt("Subpage name:"); - if (!caption?.trim()) return; + const handleAddSubpage = (parentId: number) => { + setSubpageParentId(parentId); + setSubpageName(""); + }; + + const handleCreateSubpage = async () => { + if (subpageParentId === null || !subpageName.trim()) return; + setCreatingSubpage(true); try { const result = await createCatalogPage({ - parentId, - caption: caption.trim(), + parentId: subpageParentId, + caption: subpageName.trim(), }); if (!result.ok) throw new Error(result.error); - dispatch({ type: "SET_EXPANDED", id: parentId, expanded: true }); + dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true }); await loadFullTree(); const data = result.data as Record; onSelectPage(data.id as number); onTreeMutation(); + setSubpageParentId(null); + setSubpageName(""); + toast.success("Subpage created"); } catch { toast.error("Failed to create subpage"); + } finally { + setCreatingSubpage(false); } }; @@ -457,10 +480,12 @@ export function SortableTree({ const handleDelete = async (id: number) => { const node = nodes.get(id); - if ( - !confirm(`Delete "${node?.caption ?? id}"? Children will be reparented.`) - ) - return; + const ok = await confirm({ + title: "Delete page", + description: `Delete "${node?.caption ?? id}"? Children will be reparented.`, + confirmLabel: "Delete", + }); + if (!ok) return; try { const result = await deleteTreePage({ pageId: id, mode: "reparent" }); if (!result.ok) throw new Error(); @@ -548,7 +573,12 @@ export function SortableTree({ const handleBulkDelete = async () => { const ids = [...multiSelectedIds]; - if (!confirm(`Delete ${ids.length} page(s)?`)) return; + const ok = await confirm({ + title: "Delete pages", + description: `Delete ${ids.length} page(s)? Children will be reparented.`, + confirmLabel: "Delete", + }); + if (!ok) return; await Promise.allSettled( ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })), ); @@ -598,6 +628,7 @@ export function SortableTree({ tabIndex={0} className="flex flex-col h-full border-r w-80 shrink-0" > + {confirmDialog} {/* Header */}
@@ -895,6 +926,59 @@ export function SortableTree({
)} + + { + if (!open) { + setSubpageParentId(null); + setSubpageName(""); + } + }} + > + + + New subpage + +
+ + setSubpageName(e.target.value)} + placeholder="e.g. Sofas" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateSubpage(); + }} + /> +
+ + + + +
+
); } diff --git a/src/components/admin/catalog-tree.tsx b/src/components/admin/catalog-tree.tsx index fa92a16b..e372a29e 100644 --- a/src/components/admin/catalog-tree.tsx +++ b/src/components/admin/catalog-tree.tsx @@ -32,6 +32,7 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -173,6 +174,7 @@ export function CatalogTree({ catalogType = "normal", }: CatalogTreeProps) { const router = useRouter(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [searching, setSearching] = useState(false); @@ -280,7 +282,12 @@ export function CatalogTree({ : ""; const itemInfo = node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : ""; - if (!confirm(`Delete "${node.caption}"?${childInfo}${itemInfo}`)) return; + const ok = await confirm({ + title: "Delete page", + description: `Delete "${node.caption}"?${childInfo}${itemInfo}`, + confirmLabel: "Delete", + }); + if (!ok) return; try { const res = await fetch( @@ -296,7 +303,7 @@ export function CatalogTree({ toast.error("Failed to delete page."); } }, - [router, catParam], + [router, catParam, confirm], ); const unfilteredPages = searchResults ?? rootPages; @@ -320,6 +327,7 @@ export function CatalogTree({ catalogType, }} > + {confirmDialog}
{/* Header */} diff --git a/src/components/admin/catalog/bulk-import-items.tsx b/src/components/admin/catalog/bulk-import-items.tsx index 23bf5b80..6868b780 100644 --- a/src/components/admin/catalog/bulk-import-items.tsx +++ b/src/components/admin/catalog/bulk-import-items.tsx @@ -11,7 +11,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -134,12 +133,10 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) { return ( - - - + Bulk Import Items diff --git a/src/components/admin/catalog/catalog-image-picker.tsx b/src/components/admin/catalog/catalog-image-picker.tsx index d967f12a..be05e951 100644 --- a/src/components/admin/catalog/catalog-image-picker.tsx +++ b/src/components/admin/catalog/catalog-image-picker.tsx @@ -9,7 +9,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -106,35 +105,34 @@ export function CatalogImagePicker({ return ( - - - + )} +
+

+ {label || + (type === "header" + ? "Headline image" + : type === "teaser" + ? "Teaser image" + : "Image")} +

+

+ {value || "None selected"} +

+
+ diff --git a/src/components/admin/catalog/icon-picker.tsx b/src/components/admin/catalog/icon-picker.tsx index 82783eed..2a7d0482 100644 --- a/src/components/admin/catalog/icon-picker.tsx +++ b/src/components/admin/catalog/icon-picker.tsx @@ -9,7 +9,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; @@ -103,21 +102,29 @@ export function IconPicker({ return ( - - {children ?? ( - - )} - + {children ? ( + + ) : ( + + )} Select Catalog Icon diff --git a/src/components/admin/catalog/quick-add-furni.tsx b/src/components/admin/catalog/quick-add-furni.tsx index 2c0c31b2..47f2aa18 100644 --- a/src/components/admin/catalog/quick-add-furni.tsx +++ b/src/components/admin/catalog/quick-add-furni.tsx @@ -10,7 +10,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { useServerAction } from "@/hooks/use-server-action"; @@ -122,15 +121,13 @@ export function QuickAddFurni({ return ( - - - + Quick Add Furni diff --git a/src/hooks/use-server-action.ts b/src/hooks/use-server-action.ts index ade743cd..52773390 100644 --- a/src/hooks/use-server-action.ts +++ b/src/hooks/use-server-action.ts @@ -17,6 +17,8 @@ interface RunOptions { redirectTo?: string; /** Called on success with the result data. Overrides default router.refresh(). */ onSuccess?: (data: Record) => void; + /** Called when the action fails or throws */ + onError?: () => void; } /** @@ -42,6 +44,7 @@ export function useServerAction(options: UseServerActionOptions = {}) { errorMessage = "An error occurred.", redirectTo, onSuccess, + onError, } = opts; startTransition(async () => { @@ -59,10 +62,12 @@ export function useServerAction(options: UseServerActionOptions = {}) { } } else { toast.error(result.error || errorMessage); + onError?.(); } } catch (err) { console.error("[useServerAction] Unexpected error:", err); toast.error(errorMessage); + onError?.(); } }); } diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 0fe55517..497343ff 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -245,6 +245,65 @@ export async function removeFurniEntry(classname: string): Promise { }); } +/** + * Patch name/description on existing FurnitureData entries by classname. + * Optionally creates a minimal entry when missing. + */ +export async function patchFurniEntryNames( + updates: Array<{ + classname: string; + itemType: string; + name?: string; + description?: string; + spriteId?: number; + createIfMissing?: boolean; + }>, +): Promise<{ updated: number; inserted: number }> { + if (updates.length === 0) return { updated: 0, inserted: 0 }; + + return withFurniDataLock(async () => { + const furniData = (await readFurniData()) as Record< + string, + { furnitype: Array> } + >; + let updated = 0; + let inserted = 0; + + for (const u of updates) { + const sectionKey = u.itemType === "i" ? "wallitemtypes" : "roomitemtypes"; + if (!furniData[sectionKey]) furniData[sectionKey] = { furnitype: [] }; + const section = furniData[sectionKey]; + const idx = section.furnitype.findIndex( + (e) => e.classname === u.classname, + ); + + if (idx >= 0) { + const entry = { ...section.furnitype[idx] }; + if (u.name !== undefined) entry.name = u.name; + if (u.description !== undefined) entry.description = u.description; + section.furnitype[idx] = entry; + updated++; + } else if (u.createIfMissing) { + section.furnitype.push({ + id: u.spriteId ?? 0, + classname: u.classname, + revision: 0, + category: "unknown", + name: u.name ?? u.classname, + description: u.description ?? "", + offerid: u.spriteId ?? -1, + }); + inserted++; + } + } + + if (updated > 0 || inserted > 0) { + await writeFurniData(furniData as Record); + } + return { updated, inserted }; + }); +} + export async function appendFurniEntriesBatch( entries: Array<{ entry: Record; itemType: string }>, ): Promise {