Files
EpicNext-Cms/src/components/admin/emulator-settings-form.tsx
T
Simo 8d37ae9ae0
Remote Build and Deploy / deploy (push) Has been cancelled
style: normalize restored source endings
2026-07-11 21:19:54 +02:00

412 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<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',
}
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<Record<string, string>>({})
const [originalValues, setOriginalValues] = useState<Record<string, string>>({})
const [changedKeys, setChangedKeys] = useState<Set<string>>(new Set())
const [isNavigating, startTransition] = useTransition()
const { run, isPending } = useServerAction()
const [searchValue, setSearchValue] = useState(search)
useEffect(() => {
setOriginalValues((prev) => {
let dirty = false
const next = { ...prev }
for (const s of settings) {
if (!(s.key in next)) {
next[s.key] = s.value
dirty = true
}
}
return dirty ? next : prev
})
}, [settings])
// Prefer the canonical list passed by the server (covers all groups, not
// just those visible on the current page). Fall back to deriving from the
// current page when the prop is omitted.
const groupSummary = useMemo<GroupSummary[]>(() => {
if (allGroups && allGroups.length > 0) return allGroups
const map = new Map<string, number>()
for (const s of settings) {
const g = getSettingGroup(s.key)
map.set(g, (map.get(g) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name))
}, [allGroups, settings])
function toggleGroup(name: string) {
updateParams({
group: activeGroup === name ? '' : name,
page: '1',
})
}
function 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<string, string>) {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value) params.set(key, value)
else params.delete(key)
}
startTransition(() => {
router.push(`?${params.toString()}`)
})
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
updateParams({ search: searchValue, page: '1' })
}
function handleSaveAll() {
if (changedKeys.size === 0) {
toast.info('No changes to save.')
return
}
const changedSettings: Record<string, string> = {}
for (const key of changedKeys) {
changedSettings[key] = values[key]
}
const count = changedKeys.size
run(() => saveEmulatorSettings({ settings: changedSettings }), {
successMessage: `${count} setting(s) saved and synced with emulator.`,
onSuccess: () => {
setChangedKeys(new Set())
setValues({})
// Refresh server data so the on-disk values reflect the save and the
// originalValues snapshot rebuilds on next render.
setOriginalValues({})
router.refresh()
},
})
}
function jumpToPage(raw: string) {
const n = parseInt(raw, 10)
if (!Number.isFinite(n)) return
const clamped = Math.max(1, Math.min(pagination.lastPage, n))
if (clamped !== pagination.page) updateParams({ page: String(clamped) })
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search settings..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Search
</Button>
</form>
<div className="ml-auto flex items-center gap-3">
<span className="text-sm text-muted-foreground">{pagination.total} settings</span>
{changedKeys.size > 0 && (
<Badge variant="secondary" className="gap-1">
{changedKeys.size} changed
</Badge>
)}
<Button onClick={handleSaveAll} disabled={isPending || changedKeys.size === 0} size="sm">
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
</Button>
</div>
</div>
{/* Group summary chips — click to toggle group filter */}
{groupSummary.length > 1 && (
<div className="flex flex-wrap gap-1.5 items-center">
{activeGroup && (
<button
type="button"
onClick={() => toggleGroup(activeGroup)}
className="text-[11px] underline text-muted-foreground hover:text-foreground"
>
clear
</button>
)}
{groupSummary.map(({ name, count }) => {
const isActive = activeGroup === name
const color = (GROUP_BORDER_COLORS[name] || GROUP_BORDER_COLORS.general).replace(
'border-l-',
'bg-',
)
return (
<button
key={name}
type="button"
onClick={() => toggleGroup(name)}
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md border text-[10px] font-mono transition-colors ${
isActive
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={isActive ? `Rimuovi filtro: ${name}` : `Filtra: ${name}`}
disabled={isNavigating}
>
<span className={`inline-block w-2 h-2 rounded-full ${color}`} />
{name}
<span className="text-muted-foreground">{count}</span>
</button>
)
})}
</div>
)}
{/* Settings list — flat rows with always-on inline editing */}
<div className="rounded-md border bg-card divide-y">
{settings.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No settings found.
</div>
) : (
settings.map((setting) => {
const group = getSettingGroup(setting.key)
const borderColor = GROUP_BORDER_COLORS[group] || GROUP_BORDER_COLORS.general
const isChanged = changedKeys.has(setting.key)
const display = getDisplayValue(setting)
return (
<div
key={setting.key}
className={`flex items-center gap-3 px-3 py-1.5 border-l-4 ${borderColor} ${
isChanged ? 'bg-yellow-50/60 dark:bg-yellow-950/20' : ''
}`}
>
<code
className="text-xs font-mono text-muted-foreground w-[280px] flex-shrink-0 truncate"
title={setting.key}
>
{setting.key}
</code>
<Input
value={display}
onChange={(e) => updateValue(setting.key, e.target.value)}
className={`flex-1 h-8 text-xs font-mono ${
isChanged ? 'border-yellow-500/60' : ''
}`}
/>
{isChanged && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Revert"
onClick={() => revertChange(setting.key)}
>
<RotateCcw className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
)
})
)}
</div>
{/* Pagination + per-page selector */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Per page</span>
<Select
value={String(pagination.perPage)}
onValueChange={(v) => updateParams({ perPage: v, page: '1' })}
>
<SelectTrigger size="sm" className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-2">
{pagination.total === 0
? '0 results'
: `${(pagination.page - 1) * pagination.perPage + 1}–${Math.min(
pagination.page * pagination.perPage,
pagination.total,
)} of ${pagination.total}`}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: '1' })}
title="First page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page <= 1 || isNavigating}
onClick={() => updateParams({ page: String(pagination.page - 1) })}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 mx-1 text-sm">
<Input
type="number"
min={1}
max={pagination.lastPage}
defaultValue={pagination.page}
key={pagination.page}
onBlur={(e) => jumpToPage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToPage((e.target as HTMLInputElement).value)
}}
className="h-8 w-14 text-center text-xs"
/>
<span className="text-muted-foreground">of {pagination.lastPage}</span>
</div>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.page + 1) })}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={pagination.page >= pagination.lastPage || isNavigating}
onClick={() => updateParams({ page: String(pagination.lastPage) })}
title="Last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}