"use client"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, 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"; interface Setting { key: string; value: string; } interface GroupSummary { name: string; count: number; } interface EmulatorSettingsFormProps { settings: Setting[]; pagination: { total: number; page: number; perPage: number; lastPage: number; }; search: string; allGroups?: GroupSummary[]; activeGroup?: string; } const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const; function getSettingGroup(key: string): string { const parts = key.split("."); return parts.length > 1 ? parts[0] : "general"; } const GROUP_BORDER_COLORS: Record = { hotel: "border-l-blue-500", navigator: "border-l-green-500", catalog: "border-l-purple-500", room: "border-l-orange-500", camera: "border-l-pink-500", commands: "border-l-cyan-500", wordfilter: "border-l-red-500", pet: "border-l-yellow-500", matchmaking: "border-l-indigo-500", message: "border-l-emerald-500", notification: "border-l-fuchsia-500", general: "border-l-gray-400", }; export function EmulatorSettingsForm({ settings, pagination, search, allGroups, activeGroup, }: EmulatorSettingsFormProps) { 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>({}); const [originalValues, setOriginalValues] = useState>( {}, ); const [changedKeys, setChangedKeys] = useState>(new Set()); const [isNavigating, startTransition] = useTransition(); const { run, isPending } = useServerAction(); const [searchValue, setSearchValue] = useState(search); useEffect(() => { setOriginalValues((prev) => { let dirty = false; const next = { ...prev }; for (const s of settings) { if (!(s.key in next)) { next[s.key] = s.value; dirty = true; } } return dirty ? next : prev; }); }, [settings]); // 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(() => { if (allGroups && allGroups.length > 0) return allGroups; const map = new Map(); for (const s of settings) { const g = getSettingGroup(s.key); map.set(g, (map.get(g) ?? 0) + 1); } return Array.from(map.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => a.name.localeCompare(b.name)); }, [allGroups, settings]); function toggleGroup(name: string) { updateParams({ group: activeGroup === name ? "" : name, page: "1", }); } function getDisplayValue(setting: Setting): string { return values[setting.key] ?? setting.value; } function updateValue(key: string, val: string) { setValues((prev) => ({ ...prev, [key]: val })); setChangedKeys((prev) => { const next = new Set(prev); if (originalValues[key] !== undefined && val === originalValues[key]) { next.delete(key); } else { next.add(key); } return next; }); } function revertChange(key: string) { setValues((prev) => { const next = { ...prev }; delete next[key]; return next; }); setChangedKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); } function updateParams(updates: Record) { const params = new URLSearchParams(searchParams.toString()); for (const [key, value] of Object.entries(updates)) { if (value) params.set(key, value); else params.delete(key); } startTransition(() => { router.push(`?${params.toString()}`); }); } function handleSearch(e: React.FormEvent) { e.preventDefault(); updateParams({ search: searchValue, page: "1" }); } function handleSaveAll() { if (changedKeys.size === 0) { toast.info("No changes to save."); return; } const changedSettings: Record = {}; for (const key of changedKeys) { changedSettings[key] = values[key]; } const count = changedKeys.size; run(() => saveEmulatorSettings({ settings: changedSettings }), { successMessage: `${count} setting(s) saved and synced with emulator.`, onSuccess: () => { setChangedKeys(new Set()); setValues({}); // Refresh server data so the on-disk values reflect the save and the // originalValues snapshot rebuilds on next render. setOriginalValues({}); router.refresh(); }, }); } function jumpToPage(raw: string) { const n = parseInt(raw, 10); if (!Number.isFinite(n)) return; const clamped = Math.max(1, Math.min(pagination.lastPage, n)); if (clamped !== pagination.page) updateParams({ page: String(clamped) }); } return (
{/* Toolbar */}
setSearchValue(e.target.value)} className="pl-9" />
{pagination.total} settings {changedKeys.size > 0 && ( {changedKeys.size} changed )}
{/* Group summary chips — click to toggle group filter */} {groupSummary.length > 1 && (
{activeGroup && ( )} {groupSummary.map(({ name, count }) => { const isActive = activeGroup === name; const color = ( GROUP_BORDER_COLORS[name] || GROUP_BORDER_COLORS.general ).replace("border-l-", "bg-"); return ( ); })}
)} {/* Settings list — flat rows with always-on inline editing */}
{settings.length === 0 ? (
No settings found.
) : ( settings.map((setting) => { const group = getSettingGroup(setting.key); const borderColor = GROUP_BORDER_COLORS[group] || GROUP_BORDER_COLORS.general; const isChanged = changedKeys.has(setting.key); const display = getDisplayValue(setting); return (
{setting.key} updateValue(setting.key, e.target.value)} className={`flex-1 h-8 text-xs font-mono ${ isChanged ? "border-[var(--admin-warning-border)]" : "" }`} /> {isChanged && ( )}
); }) )}
{/* Pagination + per-page selector */}
Per page {pagination.total === 0 ? "0 results" : `${(pagination.page - 1) * pagination.perPage + 1}–${Math.min( pagination.page * pagination.perPage, pagination.total, )} of ${pagination.total}`}
jumpToPage(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") jumpToPage((e.target as HTMLInputElement).value); }} className="h-8 w-14 text-center text-xs" /> of {pagination.lastPage}
); }