"use client"; import { AlertCircle, Check, ChevronDown, ChevronRight, Languages, Loader2, MonitorPlay, Package, Save, Settings, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { updateCatalogPage } from "@/actions/catalog"; import { updateBcPage } from "@/actions/catalog-bc"; import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items"; import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker"; import { type CatalogItemData, CatalogItemsTable, } from "@/components/admin/catalog/catalog-items-table"; import { CatalogTranslateTab } from "@/components/admin/catalog/catalog-translate-tab"; import { IconPicker } from "@/components/admin/catalog/icon-picker"; import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni"; import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize"; import { BcItemsEditor } from "@/components/admin/catalog-manager/bc-items-editor"; import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview"; 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 { useLatestRequest } from "@/features/catalog/client/use-latest-request"; import { CATALOG_LAYOUTS, type CatalogLayout, LAYOUT_DESCRIPTIONS, } from "@/lib/catalog-layouts"; import { cn } from "@/lib/utils"; import styles from "./catalog-access.module.css"; import { ImagePreview } from "./image-preview"; import { catalogTreeQs, 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; } interface ItemsData { items: CatalogItemData[]; baseItems: { id: number; publicName: string; itemName: string; spriteId: number; type: string; }[]; catalogNameMap: Record; furniDataIdList: number[]; furniDescriptionMap: Record; furniRevisionMap: Record; interactionTypes: string[]; allPages: { id: number; caption: string }[]; gamedataHotel?: string; } export interface PageUpdateData { id: number; caption: string; enabled: string; visible: string; pageLayout: string; iconImage: number; } interface InlineEditorProps { initialOfferId?: number | null; pageId: number | null; onSaved: (updated: PageUpdateData) => void; } type FormState = Record; export function InlineEditor(props: InlineEditorProps) { const { catalogType } = useCatalogManager(); return ( ); } function InlineEditorSession({ pageId, onSaved, initialOfferId, }: InlineEditorProps) { const pageRequests = useLatestRequest(); const itemRequests = useLatestRequest(); const saveRequests = useLatestRequest(); const tActions = useTranslations("pages.admin.actions"); const tNavigation = useTranslations("pages.admin.navigation"); const t = useTranslations("pages.admin.catalog.workspace"); const [offersDirty, setOffersDirty] = useState(false); const { dispatch, catalogType, canEdit } = useCatalogManager(); const catQs = catalogTreeQs(catalogType); const [page, setPage] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [justSaved, setJustSaved] = useState(false); const savedTimer = useRef | null>(null); useEffect( () => () => { if (savedTimer.current) clearTimeout(savedTimer.current); }, [], ); const [form, setForm] = useState({}); const [original, setOriginal] = useState({}); const [showTexts, setShowTexts] = useState(false); const [activeTab, setActiveTab] = useState( initialOfferId ? "items" : "settings", ); const [showPreview, setShowPreview] = useState(catalogType === "normal"); // Items/translate data (lazy loaded) const [itemsData, setItemsData] = useState(null); const [itemsLoading, setItemsLoading] = useState(false); const [itemsLoaded, setItemsLoaded] = useState(false); const [itemsError, setItemsError] = useState(false); // ── Dirty tracking ────────────────────────────────────────── const isDirty = useMemo(() => { return Object.keys(form).some((k) => form[k] !== original[k]); }, [form, original]); useEffect(() => { dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty }); }, [isDirty, offersDirty, dispatch]); // ── Ctrl+S handler ────────────────────────────────────────── useEffect(() => { function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); if (canEdit && isDirty && !saving && page) handleSave(); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); // biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent }, [canEdit, isDirty, saving, page, handleSave]); const loadPage = useCallback( async (id: number) => { const request = pageRequests.start(); setLoading(true); try { const res = await fetch( `/api/admin/catalog/tree?pageId=${id}${catQs}`, { signal: request.signal }, ); if (!res.ok) throw new Error(); const data = await res.json(); if (!request.isCurrent()) return; const p = data.page as PageDetail; setPage(p); const formData: FormState = { caption: p.caption, pageLayout: p.pageLayout, enabled: String(p.enabled), visible: String(p.visible), ...(catalogType === "normal" ? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") } : {}), 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); } catch { if (!request.isCurrent()) return; toast.error("Failed to load page"); setPage(null); } finally { if (request.isCurrent()) setLoading(false); } }, [catQs, catalogType, pageRequests], ); const loadItemsData = useCallback( async (id: number) => { const request = itemRequests.start(); setItemsError(false); setItemsLoading(true); try { const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, { signal: request.signal, }); if (!res.ok) throw new Error(); const data = await res.json(); if (!request.isCurrent()) return; setItemsData(data as ItemsData); setItemsLoaded(true); } catch { if (!request.isCurrent()) return; setItemsLoaded(true); setItemsError(true); toast.error("Failed to load items"); } finally { if (request.isCurrent()) setItemsLoading(false); } }, [itemRequests], ); const reloadItems = useCallback(() => { if (pageId && pageId > 0) loadItemsData(pageId); }, [pageId, loadItemsData]); useEffect(() => { if (pageId && pageId > 0) { loadPage(pageId); setItemsData(null); setItemsLoaded(false); setActiveTab(initialOfferId ? "items" : "settings"); setShowPreview(false); } else { setPage(null); setForm({}); setOriginal({}); setItemsData(null); setItemsLoaded(false); } }, [pageId, loadPage, initialOfferId]); // Lazy-load items (shop catalog only — BC items live on the detail page). // Also eager-loads while the live preview panel is open so the preview // always reflects the page's offers. useEffect(() => { if (catalogType === "bc") return; if ( (activeTab === "items" || activeTab === "translate" || showPreview) && !itemsLoaded && !itemsLoading && pageId && pageId > 0 ) { loadItemsData(pageId); } }, [ activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData, catalogType, showPreview, ]); function update(key: string, value: string | number | null) { if (!canEdit) return; setForm((prev) => ({ ...prev, [key]: value })); } async function handleSave() { if (!page || !canEdit || saving) return; const request = saveRequests.start(); setSaving(true); try { const result = catalogType === "bc" ? await updateBcPage({ id: page.id, ...form, expected: original }) : await updateCatalogPage({ id: page.id, ...form, expected: original, }); if (!request.isCurrent()) return; if (!result.ok) throw new Error(result.error || "Failed to save"); toast.success(tActions("saved")); setJustSaved(true); if (savedTimer.current) clearTimeout(savedTimer.current); savedTimer.current = setTimeout(() => setJustSaved(false), 2500); setOriginal({ ...form }); setPage((prev) => prev ? { ...prev, caption: String(form.caption || prev.caption), enabled: String(form.enabled ?? prev.enabled), visible: String(form.visible ?? prev.visible), pageLayout: String(form.pageLayout || prev.pageLayout), iconImage: (form.iconImage as number) ?? prev.iconImage, } : prev, ); onSaved({ id: page.id, caption: String(form.caption || page.caption), enabled: String(form.enabled ?? page.enabled), visible: String(form.visible ?? page.visible), pageLayout: String(form.pageLayout || page.pageLayout), iconImage: (form.iconImage as number) ?? page.iconImage, }); } catch (e) { if (request.isCurrent()) toast.error(e instanceof Error ? e.message : "Failed to save"); } finally { if (request.isCurrent()) setSaving(false); } } if (!pageId || pageId <= 0) { return (
Select a page from the tree to edit
); } if (loading) { return (
); } if (!page) return (
); const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground"; return (
{/* Header */}

{(form.caption as string) || page.caption}

{form.pageLayout as string} {saving ? ( {tActions("saving")} ) : justSaved && !isDirty && !offersDirty ? ( {tActions("saved")} ) : ( (isDirty || offersDirty) && ( {t("unsaved")} ) )}

#{page.id} · Parent:{" "} {page.parentId === -1 ? "Root" : `#${page.parentId}`} {page.itemCount !== undefined && ` · ${page.itemCount} items`}

{catalogType === "normal" && ( )}
{/* Dirty banner */} {isDirty && (
{t("saveHint")}
)} {/* Editor + live preview */}
{ if (tab === activeTab) return; if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return; setOffersDirty(false); setActiveTab(tab); }} className="flex-1 flex flex-col min-h-0" > Settings {isDirty && ( )} Items {itemsData && ( {itemsData.items.length} )} Translate {itemsData && (() => { const furniSet = new Set(itemsData.furniDataIdList); const missing = itemsData.baseItems.filter( (b) => !furniSet.has(b.id), ).length; return missing > 0 ? ( {missing} ) : null; })()} {/* ── Settings tab ──────────────────────────────── */}
{/* Caption + Layout */}
update("caption", e.target.value)} className="h-8 text-sm" />

{LAYOUT_DESCRIPTIONS[ String(form.pageLayout) as CatalogLayout ] ?? "Custom layout"}

{/* Order + Rank + Icon */}
update("orderNum", parseInt(e.target.value, 10) || 0) } className="h-8 text-sm" />
{catalogType === "normal" && (
update("minRank", parseInt(e.target.value, 10) || 1) } className="h-8 text-sm" />
)}
update("iconImage", v)} />
{/* Toggles */}
update("enabled", v ? "1" : "0")} /> update("visible", v ? "1" : "0")} /> {catalogType === "normal" && ( update("clubOnly", v ? "1" : "0")} /> )}
{/* Headline image picker */}
update("pageHeadline", v)} type="header" label="Headline image" />
{/* Teaser image picker */}
update("pageTeaser", v)} type="teaser" label="Teaser image" />
{/* Collapsible text fields */} {showTexts && (