Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

@@ -0,0 +1,248 @@
'use client'
import { Download, Gamepad2, Info, Lock, Save } from 'lucide-react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { saveClientTranslations } from '@/actions/translations'
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'
import type { ClientTranslationFile } from '@/lib/client-translation-files'
interface Props {
files: ClientTranslationFile[]
activeFile: ClientTranslationFile
entries: Record<string, string>
parseError: string | null
}
export function ClientTranslations({ files, activeFile, entries, parseError }: Props) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [data, setData] = useState<Record<string, string>>(entries)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const { run, isPending } = useServerAction()
const PER_PAGE = 50
// Re-sync local state when the user switches file (server re-renders with
// fresh entries; useState only initializes once).
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setData(entries)
setSearch('')
setPage(1)
}, [entries, activeFile.id])
const filteredKeys = useMemo(() => {
const keys = Object.keys(data)
if (!search) return keys
const q = search.toLowerCase()
return keys.filter((k) => k.toLowerCase().includes(q) || data[k].toLowerCase().includes(q))
}, [data, search])
const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE))
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE)
function changeFile(id: string) {
const params = new URLSearchParams(searchParams?.toString())
params.set('file', id)
router.push(`${pathname}?${params.toString()}`)
}
function updateValue(key: string, value: string) {
setData((d) => ({ ...d, [key]: value }))
}
function handleSave() {
if (activeFile.readOnly) return
run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
successMessage: 'File salvato.',
onSuccess: (result) => {
if (result?.commentsLost) {
toast.warning(
'Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.',
)
}
router.refresh()
},
})
}
function handleExport() {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = activeFile.relPath.split('/').pop() ?? 'translations.json'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gamepad2 className="w-6 h-6" />
Client (Nitro)
</h1>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-1" /> Export
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending || activeFile.readOnly || !!parseError}
>
<Save className="w-4 h-4 mr-1" /> {isPending ? 'Salvataggio...' : 'Salva'}
</Button>
</div>
</div>
<div className="flex gap-3 items-center flex-wrap">
<Select value={activeFile.id} onValueChange={changeFile}>
<SelectTrigger className="w-[360px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{files.map((f) => (
<SelectItem key={f.id} value={f.id}>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">{f.relPath.replace('public/', '')}</span>
{f.readOnly && (
<span className="text-[10px] uppercase text-muted-foreground">RO</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Cerca per chiave o valore..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-xs"
/>
<span className="text-sm text-muted-foreground">{filteredKeys.length} chiavi</span>
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="outline" className="font-mono text-[11px]">
{activeFile.format.toUpperCase()}
</Badge>
<Badge variant="outline" className="text-[11px]">
{activeFile.language === 'shared' ? 'shared' : activeFile.language.toUpperCase()}
</Badge>
{activeFile.readOnly && (
<Badge variant="secondary" className="gap-1 text-[11px]">
<Lock className="w-3 h-3" /> read-only
</Badge>
)}
{activeFile.hasComments && !activeFile.readOnly && (
<Badge variant="secondary" className="gap-1 text-[11px]">
<Info className="w-3 h-3" /> commenti preservati
</Badge>
)}
</div>
{activeFile.note && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{activeFile.note}
</div>
)}
{activeFile.readOnly && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400 flex items-start gap-2">
<Lock className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
Questo file è di sola lettura.{' '}
{activeFile.note ?? 'Modificarlo manualmente non è supportato dal CMS.'}
</div>
</div>
)}
{activeFile.hasComments && !activeFile.readOnly && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2 text-xs text-blue-700 dark:text-blue-400 flex items-start gap-2">
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
I commenti vengono preservati: il salvataggio modifica solo le righe dei valori
cambiati. Se una chiave usa un formato non standard ed è impossibile patchare in-place,
il file viene riserializzato (commenti persi) e ti avviseremo.
</div>
</div>
)}
{parseError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Errore di parsing: {parseError}
</div>
)}
<div className="rounded-lg border bg-card divide-y">
{pageKeys.length === 0 && !parseError && (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">Nessuna voce.</div>
)}
{pageKeys.map((key) => (
<div key={key} className="flex items-center gap-3 px-4 py-2">
<span
className="text-xs font-mono text-muted-foreground w-72 truncate flex-shrink-0"
title={key}
>
{key}
</span>
<Input
value={data[key]}
onChange={(e) => updateValue(key, e.target.value)}
className="flex-1 h-8 text-sm"
disabled={activeFile.readOnly}
/>
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Pagina {page} di {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,51 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import JSON5 from 'json5'
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
import { ClientTranslations } from './client-translations'
export default async function ClientTranslationsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>
}) {
const sp = await searchParams
const requested = sp.file
const file =
(requested && getClientTranslationFile(requested)) ||
CLIENT_TRANSLATION_FILES.find((f) => !f.readOnly) ||
CLIENT_TRANSLATION_FILES[0]
const entries: Record<string, string> = {}
let parseError: string | null = null
try {
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's tracer can't prove that — hint avoids tracing the whole project.
const raw = await fs.readFile(
path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath),
'utf-8',
)
const parsed = file.format === 'json5' ? JSON5.parse(raw) : JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// Nitro localization files are flat: { "key.with.dots": "value" }.
// Coerce values to strings so the editor can render them in <input>.
for (const [k, v] of Object.entries(parsed)) {
entries[k] = v == null ? '' : String(v)
}
} else {
parseError = 'File root is not an object.'
}
} catch (err) {
parseError = err instanceof Error ? err.message : 'Failed to read file.'
}
return (
<ClientTranslations
files={CLIENT_TRANSLATION_FILES}
activeFile={file}
entries={entries}
parseError={parseError}
/>
)
}
+11
View File
@@ -0,0 +1,11 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { TranslationsClient } from '../translations-client'
export default async function CmsTranslationsPage() {
const messagesDir = path.join(process.cwd(), 'messages')
const enContent = await fs.readFile(path.join(messagesDir, 'en.json'), 'utf-8')
const itContent = await fs.readFile(path.join(messagesDir, 'it.json'), 'utf-8')
return <TranslationsClient enJson={enContent} itJson={itContent} />
}
@@ -0,0 +1,113 @@
import { ExternalLink, Server } from 'lucide-react'
import Link from 'next/link'
import { EmulatorSettingsForm } from '@/app/[locale]/admin/settings/emulator/emulator-settings-form'
import { Button } from '@/components/ui/button'
import type { Prisma } from '@/generated/prisma/client'
import { calcPagination } from '@/lib/admin-helpers'
import { prisma } from '@/lib/prisma'
// SQL `LIKE` patterns that capture the user-facing strings inside
// `emulator_settings`. Anything not matching is config (timeouts, ports, IDs)
// and lives in /admin/settings/emulator instead.
const TRANSLATION_KEY_PATTERNS = [
'commands.description.%',
'commands.keys.%',
'commands.cmd_%',
'wordfilter.%',
'pet.%message%',
'matchmaking.%message%',
'message.%',
'notification.%',
] as const
export default async function EmulatorTranslationsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>
}) {
const sp = await searchParams
const search = sp.search || ''
const group = sp.group || ''
const perPage = Math.min(Math.max(parseInt(sp.perPage || '50', 10), 10), 200)
const page = Math.max(parseInt(sp.page || '1', 10), 1)
const patternFilters: Prisma.EmulatorSettingWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
key: { contains: p.replace(/%/g, '') },
}))
const baseFilter: Prisma.EmulatorSettingWhereInput = { OR: patternFilters }
const conditions: Prisma.EmulatorSettingWhereInput[] = [baseFilter]
if (group) conditions.push({ key: { startsWith: `${group}.` } })
if (search) {
conditions.push({
OR: [{ key: { contains: search } }, { value: { contains: search } }],
})
}
const where: Prisma.EmulatorSettingWhereInput = { AND: conditions }
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSetting.findMany({
where,
orderBy: { key: 'asc' },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorSetting.count({ where }),
// Group chips reflect the WHOLE translation-pattern subset (so they don't
// disappear when you click one); we ignore the group/search filters here.
prisma.emulatorSetting.findMany({
where: baseFilter,
select: { key: true },
}),
])
const groupCounts = new Map<string, number>()
for (const { key } of allKeys) {
const g = key.includes('.') ? key.split('.')[0] : 'general'
groupCounts.set(g, (groupCounts.get(g) ?? 0) + 1)
}
const allGroups = Array.from(groupCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name))
const pagination = calcPagination(total, page, perPage)
const settingsMap = settings.map((s) => ({ key: s.key, value: s.value }))
return (
<div className="space-y-4">
<div className="flex items-start justify-between flex-wrap gap-2">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="w-6 h-6" />
Emulator
</h1>
<p className="text-sm text-muted-foreground">
Sottoinsieme di <code className="text-xs">emulator_settings</code> con stringhe utente
(comandi, wordfilter, messaggi pet/matchmaking).
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/emulator">
<ExternalLink className="w-4 h-4 mr-1" />
Impostazioni complete
</Link>
</Button>
</div>
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Le impostazioni di configurazione (porte, timeout, ID) non compaiono qui — usa il link
«Impostazioni complete». Le modifiche salvate sono sincronizzate con l&apos;emulatore via
RCON.
</div>
<EmulatorSettingsForm
settings={settingsMap}
pagination={pagination}
search={search}
allGroups={allGroups}
activeGroup={group}
/>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { TranslationsTabs } from './translations-tabs'
export default async function TranslationsLayout({ children }: { children: React.ReactNode }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
redirect('/admin')
}
return (
<div className="space-y-6">
<TranslationsTabs />
{children}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function TranslationsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-10 w-24" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
// Default landing for /admin/translations → first tab.
// The shared TranslationsTabs (in layout.tsx) gives access to every section.
export default function TranslationsHubPage() {
redirect('/admin/translations/cms')
}
@@ -0,0 +1,179 @@
'use client'
import { Download, Globe, Save } from 'lucide-react'
import { useMemo, useState } from 'react'
import { saveTranslations } from '@/actions/translations'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useServerAction } from '@/hooks/use-server-action'
import { flattenObject, unflattenObject } from '@/lib/translations-utils'
interface Props {
enJson: string
itJson: string
}
export function TranslationsClient({ enJson, itJson }: Props) {
const [en, setEn] = useState(() => flattenObject(JSON.parse(enJson)))
const [it, setIt] = useState(() => flattenObject(JSON.parse(itJson)))
const [search, setSearch] = useState('')
const [group, setGroup] = useState('')
const [activeLang, setActiveLang] = useState<'en' | 'it'>('en')
const { run, isPending } = useServerAction()
const PER_PAGE = 30
const [page, setPage] = useState(1)
const activeData = activeLang === 'en' ? en : it
const setActiveData = activeLang === 'en' ? setEn : setIt
const groups = useMemo(() => {
const g = new Set<string>()
Object.keys(en).forEach((k) => {
const parts = k.split('.')
if (parts.length > 1) g.add(parts[0])
})
return Array.from(g).sort()
}, [en])
const filteredKeys = useMemo(() => {
return Object.keys(activeData).filter((key) => {
const matchSearch =
!search ||
key.toLowerCase().includes(search.toLowerCase()) ||
activeData[key].toLowerCase().includes(search.toLowerCase())
const matchGroup = !group || key.startsWith(`${group}.`)
return matchSearch && matchGroup
})
}, [activeData, search, group])
const totalPages = Math.ceil(filteredKeys.length / PER_PAGE)
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE)
function updateValue(key: string, value: string) {
setActiveData({ ...activeData, [key]: value })
}
function handleSave() {
run(() => saveTranslations({ locale: activeLang, data: unflattenObject(activeData) }), {
successMessage: 'Translations saved!',
})
}
function handleExport() {
const blob = new Blob([JSON.stringify(unflattenObject(activeData), null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeLang}.json`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Globe className="w-6 h-6" />
CMS
</h1>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-1" /> Export
</Button>
<Button size="sm" onClick={handleSave} disabled={isPending}>
<Save className="w-4 h-4 mr-1" /> {isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-1">
<Button
variant={activeLang === 'en' ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveLang('en')}
>
EN
</Button>
<Button
variant={activeLang === 'it' ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveLang('it')}
>
IT
</Button>
</div>
<Input
placeholder="Filter by key or value..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="max-w-xs"
/>
<select
className="rounded-md border bg-background px-3 py-1 text-sm"
value={group}
onChange={(e) => {
setGroup(e.target.value)
setPage(1)
}}
>
<option value="">All groups</option>
{groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<span className="text-sm text-muted-foreground self-center">
{filteredKeys.length} keys
</span>
</div>
<div className="rounded-lg border bg-card divide-y">
{pageKeys.map((key) => (
<div key={key} className="flex items-center gap-3 px-4 py-2">
<span className="text-xs font-mono text-muted-foreground w-64 truncate flex-shrink-0">
{key}
</span>
<Input
value={activeData[key]}
onChange={(e) => updateValue(key, e.target.value)}
className="flex-1 h-8 text-sm"
/>
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { Gamepad2, Globe, Server } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
const TABS = [
{ href: '/admin/translations/cms', label: 'CMS', icon: Globe },
{ href: '/admin/translations/client', label: 'Client (Nitro)', icon: Gamepad2 },
{ href: '/admin/translations/emulator', label: 'Emulator', icon: Server },
] as const
export function TranslationsTabs() {
const pathname = usePathname()
return (
<div className="flex gap-1 border-b">
{TABS.map((t) => {
const active = pathname === t.href || pathname.startsWith(`${t.href}/`)
const Icon = t.icon
return (
<Link
key={t.href}
href={t.href}
className={cn(
'-mb-px flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
active
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<Icon className="h-4 w-4" />
{t.label}
</Link>
)
})}
</div>
)
}