Fix admin permissions bounce, prefixes APIs, and Italian UI leftovers.
Local Build and Deploy / deploy (push) Successful in 56s

Gate permissions on ACL manage + resolve super-admin from live user ranks, restore prefixes API routes, and anglicize hardcoded admin copy with nav i18n.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:33:00 +02:00
1 parent 0e89d03940
commit 3403d3b19f
38 files changed
+673 -208

No files matched your search

+4
View File
@@ -116,10 +116,14 @@ const SETTINGS_WHITELIST = new Set([
"enabled", "enabled",
"max_length", "max_length",
"min_rank", "min_rank",
"min_rank_to_buy",
"allow_colors", "allow_colors",
"allow_bold", "allow_bold",
"allow_italic", "allow_italic",
"default_color", "default_color",
"price_credits",
"price_points",
"points_type",
]); ]);
const updatePrefixSettingsSchema = z.object({ const updatePrefixSettingsSchema = z.object({
@@ -408,7 +408,7 @@ export function NitroEditorDialog({
htmlFor="dim-y" htmlFor="dim-y"
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
> >
Y (profondità) Y (depth)
</Label> </Label>
<Input <Input
id="dim-y" id="dim-y"
@@ -817,7 +817,7 @@ export function NitroEditorDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={saving} disabled={saving}
> >
Annulla Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
@@ -829,7 +829,7 @@ export function NitroEditorDialog({
) : ( ) : (
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
)} )}
Salva Save
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>
+10 -10
View File
@@ -109,24 +109,24 @@ function getNavGroups(t: (key: string) => string) {
}, },
{ {
href: "/admin/events", href: "/admin/events",
label: "Events", label: t("events"),
icon: <Calendar size={16} />, icon: <Calendar size={16} />,
}, },
{ {
href: "/admin/polls", href: "/admin/polls",
label: "Polls", label: t("polls"),
icon: <ClipboardList size={16} />, icon: <ClipboardList size={16} />,
}, },
{ href: "/admin/banners", label: "Banners", icon: <Image size={16} /> }, { href: "/admin/banners", label: t("banners"), icon: <Image size={16} /> },
{ {
href: "/admin/prefixes", href: "/admin/prefixes",
label: "Prefixes", label: t("prefixes"),
icon: <Languages size={16} />, icon: <Languages size={16} />,
}, },
], ],
}, },
{ {
label: t("usersAccess"), label: t("usersAndAccess"),
icon: <Shield size={14} />, icon: <Shield size={14} />,
items: [ items: [
{ href: "/admin/users", label: t("users"), icon: <Users size={16} /> }, { href: "/admin/users", label: t("users"), icon: <Users size={16} /> },
@@ -150,12 +150,12 @@ function getNavGroups(t: (key: string) => string) {
}, },
{ {
href: "/admin/online", href: "/admin/online",
label: "Online Users", label: t("onlineUsers"),
icon: <Users size={16} />, icon: <Users size={16} />,
}, },
{ {
href: "/admin/moderation", href: "/admin/moderation",
label: "Moderation", label: t("moderation"),
icon: <Shield size={16} />, icon: <Shield size={16} />,
}, },
], ],
@@ -286,15 +286,15 @@ function getNavGroups(t: (key: string) => string) {
{ href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> }, { href: "/admin/logs", label: t("logs"), icon: <FileText size={16} /> },
{ {
href: "/admin/logs/audit", href: "/admin/logs/audit",
label: "Audit Log", label: t("auditLog"),
icon: <FileText size={16} />, icon: <FileText size={16} />,
}, },
{ {
href: "/admin/analytics", href: "/admin/analytics",
label: "Analytics", label: t("analytics"),
icon: <LayoutDashboard size={16} />, icon: <LayoutDashboard size={16} />,
}, },
{ href: "/admin/devops", label: "DevOps", icon: <Monitor size={16} /> }, { href: "/admin/devops", label: t("devops"), icon: <Monitor size={16} /> },
{ {
href: "/admin/wordfilter", href: "/admin/wordfilter",
label: t("wordFilter"), label: t("wordFilter"),
+1 -1
View File
@@ -168,7 +168,7 @@ export default async function AdminDashboard() {
{t("recentStaffActivity")} {t("recentStaffActivity")}
</h2> </h2>
<Link <Link
href="/admin/commandocentrum" href="/admin/logs"
className="text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent)] flex items-center gap-1 no-underline" className="text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent)] flex items-center gap-1 no-underline"
> >
{t("viewAll")} <ArrowRight size={12} /> {t("viewAll")} <ArrowRight size={12} />
+7 -3
View File
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { getAdminContext } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks"; import { fetchEmulatorRankForEdit } from "@/lib/services/permission-ranks";
import { RankEditClient } from "./rank-edit-client"; import { RankEditClient } from "./rank-edit-client";
@@ -11,8 +11,12 @@ export default async function RankEditPage({
params: Promise<{ locale: string; id: string }>; params: Promise<{ locale: string; id: string }>;
}) { }) {
const { id: rawId } = await params; const { id: rawId } = await params;
const { permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!permissions.isSuperAdmin) redirect("/admin"); if (
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
) {
redirect("/admin");
}
const rankId = parseInt(rawId, 10); const rankId = parseInt(rawId, 10);
if (Number.isNaN(rankId)) notFound(); if (Number.isNaN(rankId)) notFound();
+7 -3
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { getAdminContext } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list"; import { PermissionsList } from "./permissions-list";
@@ -8,8 +8,12 @@ import { PermissionsList } from "./permissions-list";
export default async function PermissionsPage(_props: { export default async function PermissionsPage(_props: {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}) { }) {
const { permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!permissions.isSuperAdmin) redirect("/admin"); if (
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
) {
redirect("/admin");
}
const ranks = await fetchEmulatorRankSummaries(prisma); const ranks = await fetchEmulatorRankSummaries(prisma);
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function PrefixesPage() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Custom Prefixes</h1> <h1 className="text-3xl font-bold tracking-tight">Custom Prefixes</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Gestisci i prefissi degli utenti, la blacklist e le impostazioni Manage user prefixes, blacklist and settings
</p> </p>
</div> </div>
<PrefixesClient canEdit={canEdit} /> <PrefixesClient canEdit={canEdit} />
+86 -81
View File
@@ -60,46 +60,46 @@ interface PrefixFormData {
} }
const PRESET_EFFECTS = [ const PRESET_EFFECTS = [
{ id: "", label: "Nessuno" }, { id: "", label: "None" },
{ id: "glow", label: "✨ Bagliore" }, { id: "glow", label: "✨ Glow" },
{ id: "shadow", label: "🌑 Ombra" }, { id: "shadow", label: "🌑 Shadow" },
{ id: "italic", label: "𝑰 Corsivo" }, { id: "italic", label: "𝑰 Italic" },
{ id: "outline", label: "🔲 Contorno" }, { id: "outline", label: "🔲 Outline" },
{ id: "pulse", label: "💫 Pulsante" }, { id: "pulse", label: "💫 Pulse" },
{ id: "bold-glow", label: "💡 Neon" }, { id: "bold-glow", label: "💡 Neon" },
]; ];
const PRESET_ICONS = [ const PRESET_ICONS = [
{ emoji: "", label: "Nessuna" }, { emoji: "", label: "None" },
{ emoji: "👑", label: "Corona" }, { emoji: "👑", label: "Crown" },
{ emoji: "⭐", label: "Stella" }, { emoji: "⭐", label: "Star" },
{ emoji: "❤️", label: "Cuore" }, { emoji: "❤️", label: "Heart" },
{ emoji: "💎", label: "Diamante" }, { emoji: "💎", label: "Diamond" },
{ emoji: "🔥", label: "Fuoco" }, { emoji: "🔥", label: "Fire" },
{ emoji: "⚡", label: "Fulmine" }, { emoji: "⚡", label: "Lightning" },
{ emoji: "🛡️", label: "Scudo" }, { emoji: "🛡️", label: "Shield" },
{ emoji: "💀", label: "Teschio" }, { emoji: "💀", label: "Skull" },
{ emoji: "🎵", label: "Musica" }, { emoji: "🎵", label: "Music" },
{ emoji: "💠", label: "Gemma" }, { emoji: "💠", label: "Gem" },
{ emoji: "⚔️", label: "Spade" }, { emoji: "⚔️", label: "Swords" },
{ emoji: "🏆", label: "Trofeo" }, { emoji: "🏆", label: "Trophy" },
{ emoji: "🚀", label: "Razzo" }, { emoji: "🚀", label: "Rocket" },
{ emoji: "👻", label: "Fantasma" }, { emoji: "👻", label: "Ghost" },
{ emoji: "🐾", label: "Zampa" }, { emoji: "🐾", label: "Paw" },
{ emoji: "🍃", label: "Foglia" }, { emoji: "🍃", label: "Leaf" },
{ emoji: "😎", label: "Cool" }, { emoji: "😎", label: "Cool" },
{ emoji: "🐺", label: "Lupo" }, { emoji: "🐺", label: "Wolf" },
{ emoji: "🦁", label: "Leone" }, { emoji: "🦁", label: "Lion" },
{ emoji: "🐉", label: "Drago" }, { emoji: "🐉", label: "Dragon" },
{ emoji: "🦄", label: "Unicorno" }, { emoji: "🦄", label: "Unicorn" },
{ emoji: "🌙", label: "Luna" }, { emoji: "🌙", label: "Moon" },
{ emoji: "☀️", label: "Sole" }, { emoji: "☀️", label: "Sun" },
{ emoji: "❄️", label: "Neve" }, { emoji: "❄️", label: "Snow" },
{ emoji: "🌹", label: "Rosa" }, { emoji: "🌹", label: "Rose" },
{ emoji: "🎮", label: "Controller" }, { emoji: "🎮", label: "Controller" },
{ emoji: "💰", label: "Soldi" }, { emoji: "💰", label: "Money" },
{ emoji: "🔮", label: "Sfera" }, { emoji: "🔮", label: "Orb" },
{ emoji: "🏴‍☠️", label: "Pirata" }, { emoji: "🏴‍☠️", label: "Pirate" },
]; ];
function PrefixDialog({ function PrefixDialog({
@@ -167,7 +167,7 @@ function PrefixDialog({
<div className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 space-y-4"> <div className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
{editPrefix ? "Modifica Prefisso" : "Aggiungi Prefisso"} {editPrefix ? "Edit Prefix" : "Add Prefix"}
</h3> </h3>
<button <button
type="button" type="button"
@@ -213,7 +213,7 @@ function PrefixDialog({
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Username</span> <span className="text-sm font-medium">Username</span>
<Input <Input
placeholder="Nome utente..." placeholder="Username..."
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(e) => setForm({ ...form, username: e.target.value })}
/> />
@@ -221,10 +221,10 @@ function PrefixDialog({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Testo</span> <span className="text-sm font-medium">Text</span>
<Input <Input
maxLength={15} maxLength={15}
placeholder="Testo del prefix..." placeholder="Prefix text..."
value={form.text} value={form.text}
onChange={(e) => setForm({ ...form, text: e.target.value })} onChange={(e) => setForm({ ...form, text: e.target.value })}
/> />
@@ -235,7 +235,7 @@ function PrefixDialog({
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Colore (HEX o multi: #FF0000,#00FF00) Color (HEX or multi: #FF0000,#00FF00)
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -251,7 +251,7 @@ function PrefixDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Icona</span> <span className="text-sm font-medium">Icon</span>
<select <select
className="w-full px-3 py-2 rounded border bg-background text-sm" className="w-full px-3 py-2 rounded border bg-background text-sm"
value={form.icon} value={form.icon}
@@ -266,7 +266,7 @@ function PrefixDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Effetto</span> <span className="text-sm font-medium">Effect</span>
<select <select
className="w-full px-3 py-2 rounded border bg-background text-sm" className="w-full px-3 py-2 rounded border bg-background text-sm"
value={form.effect} value={form.effect}
@@ -288,13 +288,13 @@ function PrefixDialog({
onChange={(e) => setForm({ ...form, active: e.target.checked })} onChange={(e) => setForm({ ...form, active: e.target.checked })}
/> />
<label className="text-sm" htmlFor="prefix-active"> <label className="text-sm" htmlFor="prefix-active">
Attivo Active
</label> </label>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Annulla Cancel
</Button> </Button>
<Button <Button
disabled={ disabled={
@@ -305,7 +305,7 @@ function PrefixDialog({
onClick={handleSave} onClick={handleSave}
> >
<Save className="h-4 w-4 mr-1" /> <Save className="h-4 w-4 mr-1" />
{saving ? "Salvando..." : "Salva"} {saving ? "Saving..." : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -338,6 +338,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const params = new URLSearchParams({ page: String(page) }); const params = new URLSearchParams({ page: String(page) });
if (searchQuery) params.set("q", searchQuery); if (searchQuery) params.set("q", searchQuery);
const res = await fetch(`/api/admin/prefixes?${params}`); const res = await fetch(`/api/admin/prefixes?${params}`);
if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
setPrefixes(data.prefixes); setPrefixes(data.prefixes);
@@ -347,12 +348,14 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const fetchBlacklist = useCallback(async () => { const fetchBlacklist = useCallback(async () => {
const res = await fetch("/api/admin/prefixes/blacklist"); const res = await fetch("/api/admin/prefixes/blacklist");
if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (data.ok) setBlacklist(data.words); if (data.ok) setBlacklist(data.words);
}, []); }, []);
const fetchSettings = useCallback(async () => { const fetchSettings = useCallback(async () => {
const res = await fetch("/api/admin/prefixes/settings"); const res = await fetch("/api/admin/prefixes/settings");
if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (data.ok) setSettings(data.settings); if (data.ok) setSettings(data.settings);
}, []); }, []);
@@ -367,9 +370,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
}, [fetchBlacklist, fetchSettings]); }, [fetchBlacklist, fetchSettings]);
const handleDeletePrefix = async (id: number) => { const handleDeletePrefix = async (id: number) => {
if (!confirm("Sei sicuro di voler eliminare questo prefisso?")) return; if (!confirm("Are you sure you want to delete this prefix?")) return;
run(() => deletePrefixAction({ id }), { run(() => deletePrefixAction({ id }), {
successMessage: "Prefisso eliminato!", successMessage: "Prefix deleted!",
onSuccess: () => { onSuccess: () => {
fetchPrefixes(); fetchPrefixes();
}, },
@@ -389,7 +392,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
active: data.active ? 1 : 0, active: data.active ? 1 : 0,
}), }),
{ {
successMessage: "Prefisso aggiornato!", successMessage: "Prefix updated!",
onSuccess: () => { onSuccess: () => {
fetchPrefixes(); fetchPrefixes();
}, },
@@ -407,7 +410,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
active: data.active ? 1 : 0, active: data.active ? 1 : 0,
}), }),
{ {
successMessage: "Prefisso creato!", successMessage: "Prefix created!",
onSuccess: () => { onSuccess: () => {
fetchPrefixes(); fetchPrefixes();
}, },
@@ -429,7 +432,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const handleAddBlacklistWord = async () => { const handleAddBlacklistWord = async () => {
if (!newWord.trim()) return; if (!newWord.trim()) return;
run(() => addBlacklistWordAction({ word: newWord.trim() }), { run(() => addBlacklistWordAction({ word: newWord.trim() }), {
successMessage: "Parola aggiunta alla blacklist!", successMessage: "Word added to blacklist!",
onSuccess: () => { onSuccess: () => {
setNewWord(""); setNewWord("");
fetchBlacklist(); fetchBlacklist();
@@ -439,7 +442,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const handleRemoveBlacklistWord = async (id: number) => { const handleRemoveBlacklistWord = async (id: number) => {
run(() => removeBlacklistWordAction({ id }), { run(() => removeBlacklistWordAction({ id }), {
successMessage: "Parola rimossa dalla blacklist!", successMessage: "Word removed from blacklist!",
onSuccess: () => { onSuccess: () => {
fetchBlacklist(); fetchBlacklist();
}, },
@@ -453,7 +456,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
settings: settings as unknown as Record<string, string>, settings: settings as unknown as Record<string, string>,
}), }),
{ {
successMessage: "Impostazioni salvate!", successMessage: "Settings saved!",
}, },
); );
}; };
@@ -469,9 +472,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<Tabs defaultValue="prefixes"> <Tabs defaultValue="prefixes">
<TabsList> <TabsList>
<TabsTrigger value="prefixes">Prefissi Utenti</TabsTrigger> <TabsTrigger value="prefixes">User Prefixes</TabsTrigger>
<TabsTrigger value="blacklist">Blacklist</TabsTrigger> <TabsTrigger value="blacklist">Blacklist</TabsTrigger>
<TabsTrigger value="settings">Impostazioni</TabsTrigger> <TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="prefixes" className="space-y-4"> <TabsContent value="prefixes" className="space-y-4">
@@ -480,7 +483,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
className="pl-8" className="pl-8"
placeholder="Cerca per utente o testo..." placeholder="Search by user or text..."
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -489,12 +492,12 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
</div> </div>
<Button variant="outline" onClick={fetchPrefixes}> <Button variant="outline" onClick={fetchPrefixes}>
Cerca Search
</Button> </Button>
{canEdit && ( {canEdit && (
<Button onClick={openAddDialog}> <Button onClick={openAddDialog}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Aggiungi Add
</Button> </Button>
)} )}
</div> </div>
@@ -503,14 +506,14 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>ID</TableHead>
<TableHead>Utente</TableHead> <TableHead>User</TableHead>
<TableHead>Prefix</TableHead> <TableHead>Prefix</TableHead>
<TableHead>Colore</TableHead> <TableHead>Color</TableHead>
<TableHead>Icona</TableHead> <TableHead>Icon</TableHead>
<TableHead>Effetto</TableHead> <TableHead>Effect</TableHead>
<TableHead>Attivo</TableHead> <TableHead>Active</TableHead>
<TableHead>Data</TableHead> <TableHead>Date</TableHead>
{canEdit && <TableHead>Azioni</TableHead>} {canEdit && <TableHead>Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -580,9 +583,11 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
?.label || prefix.effect ?.label || prefix.effect
: "-"} : "-"}
</TableCell> </TableCell>
<TableCell>{prefix.active ? "Si" : "No"}</TableCell> <TableCell>{prefix.active ? "Yes" : "No"}</TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{new Date(prefix.created_at).toLocaleDateString()} {prefix.created_at
? new Date(prefix.created_at).toLocaleDateString()
: "—"}
</TableCell> </TableCell>
{canEdit && ( {canEdit && (
<TableCell> <TableCell>
@@ -614,7 +619,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
colSpan={9} colSpan={9}
> >
Nessun prefisso trovato No prefixes found
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -629,10 +634,10 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
variant="outline" variant="outline"
onClick={() => setPage(page - 1)} onClick={() => setPage(page - 1)}
> >
Precedente Previous
</Button> </Button>
<span className="text-sm"> <span className="text-sm">
Pagina {page} di {totalPages} Page {page} of {totalPages}
</span> </span>
<Button <Button
disabled={page >= totalPages} disabled={page >= totalPages}
@@ -640,7 +645,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
variant="outline" variant="outline"
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
> >
Successiva Next
</Button> </Button>
</div> </div>
)} )}
@@ -649,13 +654,13 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<TabsContent value="blacklist" className="space-y-4"> <TabsContent value="blacklist" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Parole Vietate</CardTitle> <CardTitle>Forbidden Words</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{canEdit && ( {canEdit && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="Aggiungi parola..." placeholder="Add word..."
value={newWord} value={newWord}
onChange={(e) => setNewWord(e.target.value)} onChange={(e) => setNewWord(e.target.value)}
onKeyDown={(e) => onKeyDown={(e) =>
@@ -664,7 +669,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
<Button disabled={isPending} onClick={handleAddBlacklistWord}> <Button disabled={isPending} onClick={handleAddBlacklistWord}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Aggiungi Add
</Button> </Button>
</div> </div>
)} )}
@@ -689,7 +694,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
))} ))}
{blacklist.length === 0 && ( {blacklist.length === 0 && (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Nessuna parola in blacklist No blacklist words
</p> </p>
)} )}
</div> </div>
@@ -700,12 +705,12 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
<TabsContent value="settings" className="space-y-4"> <TabsContent value="settings" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Impostazioni Prefissi</CardTitle> <CardTitle>Prefix Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Lunghezza Massima</span> <span className="text-sm font-medium">Max Length</span>
<Input <Input
disabled={!canEdit} disabled={!canEdit}
type="number" type="number"
@@ -716,7 +721,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Rank Minimo</span> <span className="text-sm font-medium">Minimum Rank</span>
<Input <Input
disabled={!canEdit} disabled={!canEdit}
type="number" type="number"
@@ -730,7 +735,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Prezzo (Crediti)</span> <span className="text-sm font-medium">Price (Credits)</span>
<Input <Input
disabled={!canEdit} disabled={!canEdit}
type="number" type="number"
@@ -744,7 +749,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Prezzo (Punti)</span> <span className="text-sm font-medium">Price (Points)</span>
<Input <Input
disabled={!canEdit} disabled={!canEdit}
type="number" type="number"
@@ -755,7 +760,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">Tipo Punti</span> <span className="text-sm font-medium">Points Type</span>
<Input <Input
disabled={!canEdit} disabled={!canEdit}
type="number" type="number"
@@ -765,14 +770,14 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
0 = Duckets, 5 = Diamonds, ecc. 0 = Duckets, 5 = Diamonds, etc.
</p> </p>
</div> </div>
</div> </div>
{canEdit && ( {canEdit && (
<Button disabled={isPending} onClick={saveSettings}> <Button disabled={isPending} onClick={saveSettings}>
<Save className="h-4 w-4 mr-1" /> <Save className="h-4 w-4 mr-1" />
Salva Impostazioni Save Settings
</Button> </Button>
)} )}
</CardContent> </CardContent>
+5 -5
View File
@@ -70,11 +70,11 @@ export default async function SoundsPage() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Song Disks</h1> <h1 className="text-3xl font-bold tracking-tight">Song Disks</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Carica e gestisci i file MP3 dei song disk riprodotti dai jukebox Upload and manage MP3 files for song disks played by in-game
in-game. I file sono serviti da <code>{baseUrl}</code> (configurabile jukeboxes. Files are served from <code>{baseUrl}</code>{" "}
in{" "} (configurable in{" "}
<a href="/admin/settings/cms" className="underline"> <a href="/admin/settings" className="underline">
Impostazioni &gt; CMS Settings
</a> </a>
). ).
</p> </p>
+53 -54
View File
@@ -121,8 +121,8 @@ export function SoundsClient({
if (!deleting) return; if (!deleting) return;
const id = deleting.id; const id = deleting.id;
run(() => deleteSoundtrack({ id }), { run(() => deleteSoundtrack({ id }), {
successMessage: `Song disk #${id} eliminato.`, successMessage: `Song disk #${id} deleted.`,
errorMessage: `Impossibile eliminare il song disk #${id}.`, errorMessage: `Unable to delete song disk #${id}.`,
onSuccess: () => { onSuccess: () => {
invalidateSongPickerCache(); invalidateSongPickerCache();
setDeleting(null); setDeleting(null);
@@ -139,7 +139,7 @@ export function SoundsClient({
<Input <Input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Cerca per nome, autore, codice o id…" placeholder="Search by name, author, code or id…"
className="pl-8" className="pl-8"
/> />
</div> </div>
@@ -151,9 +151,9 @@ export function SoundsClient({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Tutti ({soundtracks.length})</SelectItem> <SelectItem value="all">All ({soundtracks.length})</SelectItem>
<SelectItem value="single"> <SelectItem value="single">
Singoli MP3 ({counts.single}) Single MP3s ({counts.single})
</SelectItem> </SelectItem>
<SelectItem value="composition"> <SelectItem value="composition">
Composition ({counts.composition}) Composition ({counts.composition})
@@ -199,15 +199,15 @@ export function SoundsClient({
{soundtracks.length === 0 ? ( {soundtracks.length === 0 ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Music className="h-8 w-8 opacity-50" /> <Music className="h-8 w-8 opacity-50" />
<p>Nessun song disk caricato.</p> <p>No song disks uploaded yet.</p>
{canEdit && ( {canEdit && (
<p className="text-xs"> <p className="text-xs">
Clicca &ldquo;Upload MP3&rdquo; per iniziare. Click &ldquo;Upload MP3&rdquo; to get started.
</p> </p>
)} )}
</div> </div>
) : ( ) : (
<>Nessun risultato per i filtri attivi.</> <>No results for the active filters.</>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -293,7 +293,7 @@ export function SoundsClient({
className="h-8 w-8" className="h-8 w-8"
onClick={() => setEditing(s)} onClick={() => setEditing(s)}
disabled={isPending} disabled={isPending}
title="Modifica" title="Edit"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -303,7 +303,7 @@ export function SoundsClient({
className="h-8 w-8 text-destructive hover:text-destructive" className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleting(s)} onClick={() => setDeleting(s)}
disabled={isPending} disabled={isPending}
title="Elimina" title="Delete"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -345,27 +345,26 @@ export function SoundsClient({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Eliminare &ldquo;{deleting.name}&rdquo;? Delete &ldquo;{deleting.name}&rdquo;?
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{deleting.usedBy > 0 ? ( {deleting.usedBy > 0 ? (
<> <>
Questo song disk è attualmente usato da{" "} This song disk is currently used by{" "}
<strong>{deleting.usedBy}</strong> catalog item <strong>{deleting.usedBy}</strong> catalog item
{deleting.usedBy === 1 ? "" : "s"}. Eliminandolo, quei {deleting.usedBy === 1 ? "" : "s"}. Deleting it will reset
catalog item verranno riportati a &ldquo;nessun song those catalog items to &ldquo;no song disk&rdquo;.
disk&rdquo;.
{deleting.kind === "single" && ( {deleting.kind === "single" && (
<> Il file MP3 sarà rimosso dal disco.</> <> The MP3 file will be removed from disk.</>
)}{" "} )}{" "}
L&apos;azione è irreversibile. This action cannot be undone.
</> </>
) : ( ) : (
<> <>
{deleting.kind === "single" {deleting.kind === "single"
? "Il file MP3 sarà rimosso dal disco e la riga dal database." ? "The MP3 file will be removed from disk and the row from the database."
: "La riga sarà rimossa dal database. Nessun file viene eliminato (una composition non ha MP3 dedicato)."}{" "} : "The row will be removed from the database. No file is deleted (a composition has no dedicated MP3)."}{" "}
L&apos;azione è irreversibile. This action cannot be undone.
</> </>
)} )}
</DialogDescription> </DialogDescription>
@@ -376,7 +375,7 @@ export function SoundsClient({
onClick={() => setDeleting(null)} onClick={() => setDeleting(null)}
disabled={isPending} disabled={isPending}
> >
Annulla Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -384,7 +383,7 @@ export function SoundsClient({
disabled={isPending} disabled={isPending}
> >
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />} {isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
Elimina Delete
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -420,11 +419,11 @@ function UploadDialog({
e.preventDefault(); e.preventDefault();
const file = fileRef.current?.files?.[0]; const file = fileRef.current?.files?.[0];
if (!file) { if (!file) {
toast.error("Seleziona un file MP3."); toast.error("Select an MP3 file.");
return; return;
} }
if (!name.trim()) { if (!name.trim()) {
toast.error("Il nome è obbligatorio."); toast.error("Name is required.");
return; return;
} }
@@ -445,16 +444,16 @@ function UploadDialog({
id?: number; id?: number;
}; };
if (!res.ok || !json.ok) { if (!res.ok || !json.ok) {
toast.error(json.error || "Upload fallito."); toast.error(json.error || "Upload failed.");
return; return;
} }
toast.success(`Song disk #${json.id} caricato.`); toast.success(`Song disk #${json.id} uploaded.`);
reset(); reset();
onOpenChange(false); onOpenChange(false);
onUploaded(); onUploaded();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Errore di rete durante l'upload."); toast.error("Network error during upload.");
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -471,14 +470,14 @@ function UploadDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Upload Song Disk</DialogTitle> <DialogTitle>Upload Song Disk</DialogTitle>
<DialogDescription> <DialogDescription>
Carica un file MP3. Dimensione massima 10 MB. Il codice sarà Upload an MP3 file. Max size 10 MB. The code will be generated
generato automaticamente come <code>song_&#123;id&#125;</code>. automatically as <code>song_&#123;id&#125;</code>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="sound-file">File MP3</Label> <Label htmlFor="sound-file">MP3 file</Label>
<Input <Input
id="sound-file" id="sound-file"
type="file" type="file"
@@ -488,23 +487,23 @@ function UploadDialog({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="sound-name">Nome</Label> <Label htmlFor="sound-name">Name</Label>
<Input <Input
id="sound-name" id="sound-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Titolo del brano" placeholder="Track title"
maxLength={100} maxLength={100}
required required
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="sound-author">Autore</Label> <Label htmlFor="sound-author">Author</Label>
<Input <Input
id="sound-author" id="sound-author"
value={author} value={author}
onChange={(e) => setAuthor(e.target.value)} onChange={(e) => setAuthor(e.target.value)}
placeholder="Autore (opzionale)" placeholder="Author (optional)"
maxLength={50} maxLength={50}
/> />
</div> </div>
@@ -516,7 +515,7 @@ function UploadDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={uploading} disabled={uploading}
> >
Annulla Cancel
</Button> </Button>
<Button type="submit" disabled={uploading}> <Button type="submit" disabled={uploading}>
{uploading ? ( {uploading ? (
@@ -524,7 +523,7 @@ function UploadDialog({
) : ( ) : (
<Upload className="h-4 w-4 mr-1" /> <Upload className="h-4 w-4 mr-1" />
)} )}
Carica Upload
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -555,12 +554,12 @@ function EditDialog({
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
toast.error("Il nome è obbligatorio."); toast.error("Name is required.");
return; return;
} }
const lenNum = Number.parseInt(length, 10); const lenNum = Number.parseInt(length, 10);
if (!Number.isFinite(lenNum) || lenNum < 0) { if (!Number.isFinite(lenNum) || lenNum < 0) {
toast.error("La durata deve essere un numero ≥ 0."); toast.error("Length must be a number ≥ 0.");
return; return;
} }
run( run(
@@ -573,8 +572,8 @@ function EditDialog({
length: lenNum, length: lenNum,
}), }),
{ {
successMessage: "Song disk aggiornato.", successMessage: "Song disk updated.",
errorMessage: "Impossibile aggiornare il song disk.", errorMessage: "Unable to update the song disk.",
onSuccess: onSaved, onSuccess: onSaved,
}, },
); );
@@ -585,21 +584,21 @@ function EditDialog({
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Modifica {isComposition ? "Trax Composition" : "Song Disk"} Edit {isComposition ? "Trax Composition" : "Song Disk"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span className="font-mono text-xs">{soundtrack.code}</span> <span className="font-mono text-xs">{soundtrack.code}</span>
{isComposition ? ( {isComposition ? (
<> <>
{" "} {" "}
— composition di {soundtrack.samples.length} sample. Modifica il — composition of {soundtrack.samples.length} samples. Edit the{" "}
campo <code>track</code> solo se sai cosa stai facendo. <code>track</code> field only if you know what you are doing.
</> </>
) : ( ) : (
<> <>
{" "} {" "}
— il file MP3 non può essere sostituito: elimina e ricarica se — the MP3 file cannot be replaced: delete and re-upload if you
devi cambiare audio. need to change the audio.
</> </>
)} )}
</DialogDescription> </DialogDescription>
@@ -607,7 +606,7 @@ function EditDialog({
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="edit-name">Nome</Label> <Label htmlFor="edit-name">Name</Label>
<Input <Input
id="edit-name" id="edit-name"
value={name} value={name}
@@ -617,7 +616,7 @@ function EditDialog({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="edit-author">Autore</Label> <Label htmlFor="edit-author">Author</Label>
<Input <Input
id="edit-author" id="edit-author"
value={author} value={author}
@@ -626,7 +625,7 @@ function EditDialog({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="edit-length">Durata (secondi)</Label> <Label htmlFor="edit-length">Length (seconds)</Label>
<Input <Input
id="edit-length" id="edit-length"
type="number" type="number"
@@ -636,8 +635,8 @@ function EditDialog({
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{isComposition {isComposition
? "Durata totale della composition. Lascia 0 se sconosciuta." ? "Total composition length. Leave 0 if unknown."
: "Impostato automaticamente dall’upload. Modifica solo se errato."} : "Set automatically from the upload. Edit only if incorrect."}
</p> </p>
</div> </div>
@@ -653,8 +652,8 @@ function EditDialog({
placeholder="1:371,8;374,4;378,4;0,4;…" placeholder="1:371,8;374,4;378,4;0,4;…"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Formato: <code>canale:sample_id,durata;sample_id,durata;…</code>{" "} Format: <code>channel:sample_id,length;sample_id,length;…</code>{" "}
— <code>0</code> = silenzio. Sample IDs rilevati:{" "} — <code>0</code> = silence. Detected sample IDs:{" "}
<span className="font-mono"> <span className="font-mono">
{soundtrack.samples.join(", ") || "—"} {soundtrack.samples.join(", ") || "—"}
</span> </span>
@@ -670,11 +669,11 @@ function EditDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={isPending} disabled={isPending}
> >
Annulla Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />} {isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
Salva Save
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -74,11 +74,11 @@ export function ClientTranslations({
function handleSave() { function handleSave() {
if (activeFile.readOnly) return; if (activeFile.readOnly) return;
run(() => saveClientTranslations({ fileId: activeFile.id, data }), { run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
successMessage: "File salvato.", successMessage: "File saved.",
onSuccess: (result) => { onSuccess: (result) => {
if (result?.commentsLost) { if (result?.commentsLost) {
toast.warning( toast.warning(
"Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.", "Some keys did not match the standard format: file was reserialized and comments were removed.",
); );
} }
router.refresh(); router.refresh();
@@ -115,7 +115,7 @@ export function ClientTranslations({
disabled={isPending || activeFile.readOnly || !!parseError} disabled={isPending || activeFile.readOnly || !!parseError}
> >
<Save className="w-4 h-4 mr-1" />{" "} <Save className="w-4 h-4 mr-1" />{" "}
{isPending ? "Salvataggio..." : "Salva"} {isPending ? "Saving..." : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -144,7 +144,7 @@ export function ClientTranslations({
</Select> </Select>
<Input <Input
placeholder="Cerca per chiave o valore..." placeholder="Search by key or value..."
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -154,7 +154,7 @@ export function ClientTranslations({
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{filteredKeys.length} chiavi {filteredKeys.length} keys
</span> </span>
</div> </div>
@@ -174,7 +174,7 @@ export function ClientTranslations({
)} )}
{activeFile.hasComments && !activeFile.readOnly && ( {activeFile.hasComments && !activeFile.readOnly && (
<Badge variant="secondary" className="gap-1 text-[11px]"> <Badge variant="secondary" className="gap-1 text-[11px]">
<Info className="w-3 h-3" /> commenti preservati <Info className="w-3 h-3" /> comments preserved
</Badge> </Badge>
)} )}
</div> </div>
@@ -189,9 +189,9 @@ export function ClientTranslations({
<div className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-3 py-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)] flex items-start gap-2"> <div className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-3 py-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)] flex items-start gap-2">
<Lock className="w-4 h-4 flex-shrink-0 mt-0.5" /> <Lock className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div> <div>
Questo file è di sola lettura.{" "} This file is read-only.{" "}
{activeFile.note ?? {activeFile.note ??
"Modificarlo manualmente non è supportato dal CMS."} "Manual editing is not supported by the CMS."}
</div> </div>
</div> </div>
)} )}
@@ -200,24 +200,23 @@ export function ClientTranslations({
<div className="rounded-md border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-3 py-2 text-xs text-[var(--admin-info)] dark:text-[var(--admin-info)] flex items-start gap-2"> <div className="rounded-md border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-3 py-2 text-xs text-[var(--admin-info)] dark:text-[var(--admin-info)] flex items-start gap-2">
<Info className="w-4 h-4 flex-shrink-0 mt-0.5" /> <Info className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div> <div>
I commenti vengono preservati: il salvataggio modifica solo le righe Comments are preserved: saving only patches changed value lines. If
dei valori cambiati. Se una chiave usa un formato non standard ed è a key uses a non-standard format and cannot be patched in-place, the
impossibile patchare in-place, il file viene riserializzato file is reserialized (comments lost) and you will be warned.
(commenti persi) e ti avviseremo.
</div> </div>
</div> </div>
)} )}
{parseError && ( {parseError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Errore di parsing: {parseError} Parse error: {parseError}
</div> </div>
)} )}
<div className="rounded-lg border bg-card divide-y"> <div className="rounded-lg border bg-card divide-y">
{pageKeys.length === 0 && !parseError && ( {pageKeys.length === 0 && !parseError && (
<div className="px-4 py-6 text-sm text-muted-foreground text-center"> <div className="px-4 py-6 text-sm text-muted-foreground text-center">
Nessuna voce. No entries.
</div> </div>
)} )}
{pageKeys.map((key) => ( {pageKeys.map((key) => (
@@ -241,7 +240,7 @@ export function ClientTranslations({
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Pagina {page} di {totalPages} Page {page} of {totalPages}
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
+1 -1
View File
@@ -83,7 +83,7 @@ export default async function EmulatorTranslationsPage({
<p className="text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href="/admin/settings/emulator"> <Link href="/admin/emulator">
<ExternalLink className="w-4 h-4 mr-1" /> <ExternalLink className="w-4 h-4 mr-1" />
{t("fullSettings")} {t("fullSettings")}
</Link> </Link>
@@ -0,0 +1,48 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
interface BlacklistWord {
id: number;
word: string;
}
export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
const words = await prisma.$queryRaw<BlacklistWord[]>`
SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC
`;
return apiOk({
words: words.map((w) => ({ ...w, created_at: "" })),
});
});
export const POST = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const word = typeof body.word === "string" ? body.word.trim() : "";
if (!word) return apiError("Word is required");
await prisma.$executeRaw`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`;
return apiOk();
},
);
export const DELETE = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const id = Number(body.id);
if (!Number.isInteger(id) || id <= 0)
return apiError("Missing or invalid word id");
await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`;
return apiOk({ deleted: id });
},
);
+124
View File
@@ -0,0 +1,124 @@
import { Prisma } from "@/generated/prisma/client";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
interface UserPrefix {
id: number;
user_id: number;
text: string;
color: string;
icon: string;
effect: string;
active: number;
username?: string | null;
}
export const GET = withAdmin(
{ permission: PERMS.PREFIXES_VIEW },
async (request) => {
const url = request.nextUrl;
const q = url.searchParams.get("q") || "";
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const limit = 50;
const offset = (page - 1) * limit;
const whereFragment = q
? Prisma.sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}`
: Prisma.empty;
const prefixes = await prisma.$queryRaw<UserPrefix[]>(
Prisma.sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username
FROM custom_prefixes up
LEFT JOIN users u ON u.id = up.user_id
${whereFragment}
ORDER BY up.id DESC
LIMIT ${limit} OFFSET ${offset}`,
);
const countResult = await prisma.$queryRaw<[{ total: bigint }]>(
Prisma.sql`SELECT COUNT(*) as total FROM custom_prefixes up
LEFT JOIN users u ON u.id = up.user_id
${whereFragment}`,
);
const total = Number(countResult[0]?.total || 0);
return apiOk({
prefixes: prefixes.map((p) => ({
...p,
icon: p.icon || "",
effect: p.effect || "",
active: Boolean(p.active),
created_at: "",
})),
total,
page,
pages: Math.max(1, Math.ceil(total / limit)),
});
},
);
export const POST = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const { username, text, color, icon, effect, active } = body;
if (!username || !text || !color) {
return apiError("Missing required fields: username, text, color");
}
const users = await prisma.$queryRaw<{ id: number }[]>(
Prisma.sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`,
);
if (!users || users.length === 0) {
return apiError("User not found");
}
const userId = users[0].id;
await prisma.$executeRaw(
Prisma.sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
VALUES (${userId}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active ? 1 : 0})`,
);
return apiOk({ created: true });
},
);
export const PUT = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const { id, text, color, icon, effect, active } = body;
if (!id) return apiError("Missing prefix id");
await prisma.$executeRaw(
Prisma.sql`UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ? 1 : 0}
WHERE id = ${id}`,
);
return apiOk({ updated: id });
},
);
export const DELETE = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const id = body.id;
if (!id) return apiError("Missing prefix id");
await prisma.$executeRaw(
Prisma.sql`DELETE FROM custom_prefixes WHERE id = ${id}`,
);
return apiOk({ deleted: id });
},
);
@@ -0,0 +1,70 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
interface PrefixSetting {
key: string;
value: string;
}
const VALID_KEYS = new Set([
"enabled",
"max_length",
"min_rank",
"min_rank_to_buy",
"allow_colors",
"allow_bold",
"allow_italic",
"default_color",
"price_credits",
"price_points",
"points_type",
]);
const DEFAULT_SETTINGS: Record<string, string> = {
max_length: "15",
min_rank_to_buy: "1",
price_credits: "5",
price_points: "0",
points_type: "0",
};
export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
const settings = await prisma.$queryRaw<PrefixSetting[]>`
SELECT \`key\`, \`value\` FROM custom_prefix_settings
`;
const result: Record<string, string> = { ...DEFAULT_SETTINGS };
for (const s of settings) {
result[s.key] = s.value;
}
return apiOk({ settings: result });
});
export const PUT = withAdmin(
{ permission: PERMS.PREFIXES_EDIT },
async (request) => {
const body = await request.json();
const settings = body.settings;
if (!settings || typeof settings !== "object") {
return apiError("Settings object is required");
}
for (const [key, value] of Object.entries(settings)) {
if (!VALID_KEYS.has(key)) {
return apiError(`Invalid setting key: ${key}`);
}
const strValue = String(value);
await prisma.$executeRaw`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${strValue})
ON DUPLICATE KEY UPDATE \`value\` = ${strValue}
`;
}
return apiOk();
},
);
+11 -1
View File
@@ -104,10 +104,20 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
resolveAuthorizationState(userId, { resolveAuthorizationState(userId, {
user: prisma.user, user: prisma.user,
highestRank: async () => { highestRank: async () => {
// Prefer the highest rank actually held by a user. Unused high IDs in
// permission_ranks (common on Habbo DBs) would otherwise lock the real
// owner out of super-admin / permissions management.
const rows = await prisma.$queryRaw< const rows = await prisma.$queryRaw<
{ highest_rank: number | bigint | null }[] { highest_rank: number | bigint | null }[]
>` >`
SELECT MAX(id) AS highest_rank FROM permission_ranks SELECT COALESCE(
(
SELECT MAX(u.\`rank\`)
FROM users u
INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`
),
(SELECT MAX(id) FROM permission_ranks)
) AS highest_rank
`; `;
return rows[0]?.highest_rank == null return rows[0]?.highest_rank == null
? null ? null
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Кутии за писане", "writeableBoxes": "Кутии за писане",
"photos": "Снимки", "photos": "Снимки",
"tickets": "Билети", "tickets": "Билети",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Потребители и достъп", "usersAndAccess": "Потребители и достъп",
"users": "Потребители", "users": "Потребители",
"bans": "забрани", "bans": "забрани",
@@ -706,6 +710,8 @@
"applications": "Приложения", "applications": "Приложения",
"teams": "Екипи", "teams": "Екипи",
"permissions": "Разрешения", "permissions": "Разрешения",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "икономичност", "economy": "икономичност",
"catalog": "Каталог", "catalog": "Каталог",
"shop": "Магазин", "shop": "Магазин",
@@ -732,6 +738,9 @@
"emulator": "Емулатор", "emulator": "Емулатор",
"alerts": "Сигнали", "alerts": "Сигнали",
"logs": "трупи", "logs": "трупи",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Филтър за думи", "wordFilter": "Филтър за думи",
"maintenance": "Поддръжка", "maintenance": "Поддръжка",
"rooms": "Стаи", "rooms": "Стаи",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Popsatelné krabice", "writeableBoxes": "Popsatelné krabice",
"photos": "Fotografie", "photos": "Fotografie",
"tickets": "Vstupenky", "tickets": "Vstupenky",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Uživatelé a přístup", "usersAndAccess": "Uživatelé a přístup",
"users": "Uživatelé", "users": "Uživatelé",
"bans": "Zákazy", "bans": "Zákazy",
@@ -706,6 +710,8 @@
"applications": "Aplikace", "applications": "Aplikace",
"teams": "Týmy", "teams": "Týmy",
"permissions": "Oprávnění", "permissions": "Oprávnění",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Ekonomika", "economy": "Ekonomika",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Obchod", "shop": "Obchod",
@@ -732,6 +738,9 @@
"emulator": "Emulátor", "emulator": "Emulátor",
"alerts": "Upozornění", "alerts": "Upozornění",
"logs": "Protokoly", "logs": "Protokoly",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtr slov", "wordFilter": "Filtr slov",
"maintenance": "Údržba", "maintenance": "Údržba",
"rooms": "Pokoje", "rooms": "Pokoje",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Skrivbare kasser", "writeableBoxes": "Skrivbare kasser",
"photos": "Fotos", "photos": "Fotos",
"tickets": "Billetter", "tickets": "Billetter",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Brugere og adgang", "usersAndAccess": "Brugere og adgang",
"users": "Brugere", "users": "Brugere",
"bans": "Forbud", "bans": "Forbud",
@@ -706,6 +710,8 @@
"applications": "Ansøgninger", "applications": "Ansøgninger",
"teams": "Hold", "teams": "Hold",
"permissions": "Tilladelser", "permissions": "Tilladelser",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Økonomi", "economy": "Økonomi",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Butik", "shop": "Butik",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Advarsler", "alerts": "Advarsler",
"logs": "Logs", "logs": "Logs",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Ord filter", "wordFilter": "Ord filter",
"maintenance": "Vedligeholdelse", "maintenance": "Vedligeholdelse",
"rooms": "Værelser", "rooms": "Værelser",
+15 -6
View File
@@ -662,6 +662,10 @@
"writeableBoxes": "Editierbare Boxen", "writeableBoxes": "Editierbare Boxen",
"photos": "Fotos", "photos": "Fotos",
"tickets": "Tickets", "tickets": "Tickets",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Benutzer & Zugang", "usersAndAccess": "Benutzer & Zugang",
"users": "Benutzer", "users": "Benutzer",
"bans": "Banns", "bans": "Banns",
@@ -670,6 +674,8 @@
"applications": "Bewerbungen", "applications": "Bewerbungen",
"teams": "Teams", "teams": "Teams",
"permissions": "Berechtigungen", "permissions": "Berechtigungen",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Wirtschaft", "economy": "Wirtschaft",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Shop", "shop": "Shop",
@@ -696,21 +702,24 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Alarme", "alerts": "Alarme",
"logs": "Logs", "logs": "Logs",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Wortfilter", "wordFilter": "Wortfilter",
"maintenance": "Wartung", "maintenance": "Wartung",
"housekeeping": "Hausverwaltung",
"rooms": "Räume", "rooms": "Räume",
"emailTemplates": "E-Mail-Vorlagen", "emailTemplates": "E-Mail-Vorlagen",
"theme": "Design", "theme": "Design",
"media": "Medien", "media": "Medien",
"settings": "Einstellungen",
"backToSite": "Zurück zur Website",
"hotelManagementPanel": "Hotel-Verwaltungsbereich",
"rankLabel": "Rang {rank}",
"favicon": "Favicon", "favicon": "Favicon",
"sounds": "Sounds", "sounds": "Sounds",
"translations": "Übersetzungen", "translations": "Übersetzungen",
"import": "Importieren" "import": "Importieren",
"settings": "Einstellungen",
"backToSite": "Zurück zur Website",
"housekeeping": "Hausverwaltung",
"hotelManagementPanel": "Hotel-Verwaltungsbereich",
"rankLabel": "Rang {rank}"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Εγγράψιμα κουτιά", "writeableBoxes": "Εγγράψιμα κουτιά",
"photos": "Φωτογραφίες", "photos": "Φωτογραφίες",
"tickets": "Εισιτήρια", "tickets": "Εισιτήρια",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Χρήστες και πρόσβαση", "usersAndAccess": "Χρήστες και πρόσβαση",
"users": "Χρήστες", "users": "Χρήστες",
"bans": "Απαγορεύσεις", "bans": "Απαγορεύσεις",
@@ -706,6 +710,8 @@
"applications": "Εφαρμογές", "applications": "Εφαρμογές",
"teams": "Ομάδες", "teams": "Ομάδες",
"permissions": "Άδειες", "permissions": "Άδειες",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Οικονομία", "economy": "Οικονομία",
"catalog": "Κατάλογος", "catalog": "Κατάλογος",
"shop": "Κατάστημα", "shop": "Κατάστημα",
@@ -732,6 +738,9 @@
"emulator": "Εξομοιωτής", "emulator": "Εξομοιωτής",
"alerts": "Ειδοποιήσεις", "alerts": "Ειδοποιήσεις",
"logs": "κούτσουρα", "logs": "κούτσουρα",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Φίλτρο λέξεων", "wordFilter": "Φίλτρο λέξεων",
"maintenance": "Συντήρηση", "maintenance": "Συντήρηση",
"rooms": "Δωμάτια", "rooms": "Δωμάτια",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Writeable boxes", "writeableBoxes": "Writeable boxes",
"photos": "Photos", "photos": "Photos",
"tickets": "Tickets", "tickets": "Tickets",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Users & access", "usersAndAccess": "Users & access",
"users": "Users", "users": "Users",
"bans": "Bans", "bans": "Bans",
@@ -706,6 +710,8 @@
"applications": "Applications", "applications": "Applications",
"teams": "Teams", "teams": "Teams",
"permissions": "Permissions", "permissions": "Permissions",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Economy", "economy": "Economy",
"catalog": "Catalog", "catalog": "Catalog",
"shop": "Shop", "shop": "Shop",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Alerts", "alerts": "Alerts",
"logs": "Logs", "logs": "Logs",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Word filter", "wordFilter": "Word filter",
"maintenance": "Maintenance", "maintenance": "Maintenance",
"rooms": "Rooms", "rooms": "Rooms",
+15 -6
View File
@@ -662,6 +662,10 @@
"writeableBoxes": "Cajas editables", "writeableBoxes": "Cajas editables",
"photos": "Fotos", "photos": "Fotos",
"tickets": "Tickets", "tickets": "Tickets",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Usuarios y acceso", "usersAndAccess": "Usuarios y acceso",
"users": "Usuarios", "users": "Usuarios",
"bans": "Sanciones", "bans": "Sanciones",
@@ -670,6 +674,8 @@
"applications": "Solicitudes", "applications": "Solicitudes",
"teams": "Equipos", "teams": "Equipos",
"permissions": "Permisos", "permissions": "Permisos",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Economía", "economy": "Economía",
"catalog": "Catálogo", "catalog": "Catálogo",
"shop": "Tienda", "shop": "Tienda",
@@ -696,21 +702,24 @@
"emulator": "Emulador", "emulator": "Emulador",
"alerts": "Alertas", "alerts": "Alertas",
"logs": "Registros", "logs": "Registros",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtro de palabras", "wordFilter": "Filtro de palabras",
"maintenance": "Mantenimiento", "maintenance": "Mantenimiento",
"housekeeping": "Housekeeping",
"rooms": "Salas", "rooms": "Salas",
"emailTemplates": "Plantillas de correo", "emailTemplates": "Plantillas de correo",
"theme": "Tema", "theme": "Tema",
"media": "Multimedia", "media": "Multimedia",
"settings": "Ajustes",
"backToSite": "Volver al sitio",
"hotelManagementPanel": "Panel de gestión del hotel",
"rankLabel": "Rango {rank}",
"favicon": "Favicon", "favicon": "Favicon",
"sounds": "Sonidos", "sounds": "Sonidos",
"translations": "Traducciones", "translations": "Traducciones",
"import": "Importar" "import": "Importar",
"settings": "Ajustes",
"backToSite": "Volver al sitio",
"housekeeping": "Housekeeping",
"hotelManagementPanel": "Panel de gestión del hotel",
"rankLabel": "Rango {rank}"
}, },
"dashboard": { "dashboard": {
"title": "Panel", "title": "Panel",
+15 -6
View File
@@ -662,6 +662,10 @@
"writeableBoxes": "Boîtes modifiables", "writeableBoxes": "Boîtes modifiables",
"photos": "Photos", "photos": "Photos",
"tickets": "Tickets", "tickets": "Tickets",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Utilisateurs & accès", "usersAndAccess": "Utilisateurs & accès",
"users": "Utilisateurs", "users": "Utilisateurs",
"bans": "Bannissements", "bans": "Bannissements",
@@ -670,6 +674,8 @@
"applications": "Candidatures", "applications": "Candidatures",
"teams": "Équipes", "teams": "Équipes",
"permissions": "Permissions", "permissions": "Permissions",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Économie", "economy": "Économie",
"catalog": "Catalogue", "catalog": "Catalogue",
"shop": "Boutique", "shop": "Boutique",
@@ -696,21 +702,24 @@
"emulator": "Émulateur", "emulator": "Émulateur",
"alerts": "Alertes", "alerts": "Alertes",
"logs": "Journaux", "logs": "Journaux",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtre de mots", "wordFilter": "Filtre de mots",
"maintenance": "Maintenance", "maintenance": "Maintenance",
"housekeeping": "Gouvernance",
"rooms": "Salons", "rooms": "Salons",
"emailTemplates": "Modèles d'e-mail", "emailTemplates": "Modèles d'e-mail",
"theme": "Thème", "theme": "Thème",
"media": "Médias", "media": "Médias",
"settings": "Paramètres",
"backToSite": "Retour au site",
"hotelManagementPanel": "Panneau de gestion de l'hôtel",
"rankLabel": "Rang {rank}",
"favicon": "Favicon", "favicon": "Favicon",
"sounds": "Sons", "sounds": "Sons",
"translations": "Traductions", "translations": "Traductions",
"import": "Importer" "import": "Importer",
"settings": "Paramètres",
"backToSite": "Retour au site",
"housekeeping": "Gouvernance",
"hotelManagementPanel": "Panneau de gestion de l'hôtel",
"rankLabel": "Rang {rank}"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Kutije za pisanje", "writeableBoxes": "Kutije za pisanje",
"photos": "Fotografije", "photos": "Fotografije",
"tickets": "Ulaznice", "tickets": "Ulaznice",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Korisnici i pristup", "usersAndAccess": "Korisnici i pristup",
"users": "Korisnici", "users": "Korisnici",
"bans": "Zabrane", "bans": "Zabrane",
@@ -706,6 +710,8 @@
"applications": "Prijave", "applications": "Prijave",
"teams": "Timovi", "teams": "Timovi",
"permissions": "Dozvole", "permissions": "Dozvole",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Gospodarstvo", "economy": "Gospodarstvo",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Trgovina", "shop": "Trgovina",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "upozorenja", "alerts": "upozorenja",
"logs": "Dnevnici", "logs": "Dnevnici",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filter riječi", "wordFilter": "Filter riječi",
"maintenance": "Održavanje", "maintenance": "Održavanje",
"rooms": "Sobe", "rooms": "Sobe",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Írható dobozok", "writeableBoxes": "Írható dobozok",
"photos": "Fényképek", "photos": "Fényképek",
"tickets": "Jegyek", "tickets": "Jegyek",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Felhasználók és hozzáférés", "usersAndAccess": "Felhasználók és hozzáférés",
"users": "Felhasználók", "users": "Felhasználók",
"bans": "Tiltások", "bans": "Tiltások",
@@ -706,6 +710,8 @@
"applications": "Alkalmazások", "applications": "Alkalmazások",
"teams": "Csapatok", "teams": "Csapatok",
"permissions": "Engedélyek", "permissions": "Engedélyek",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Gazdaság", "economy": "Gazdaság",
"catalog": "Katalógus", "catalog": "Katalógus",
"shop": "Bolt", "shop": "Bolt",
@@ -732,6 +738,9 @@
"emulator": "Emulátor", "emulator": "Emulátor",
"alerts": "Figyelmeztetések", "alerts": "Figyelmeztetések",
"logs": "Naplók", "logs": "Naplók",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Szószűrő", "wordFilter": "Szószűrő",
"maintenance": "Karbantartás", "maintenance": "Karbantartás",
"rooms": "Szobák", "rooms": "Szobák",
+9
View File
@@ -662,6 +662,10 @@
"writeableBoxes": "Box modificabili", "writeableBoxes": "Box modificabili",
"photos": "Foto", "photos": "Foto",
"tickets": "Ticket", "tickets": "Ticket",
"events": "Eventi",
"polls": "Sondaggi",
"banners": "Banner",
"prefixes": "Prefissi",
"usersAndAccess": "Utenti e accesso", "usersAndAccess": "Utenti e accesso",
"users": "Utenti", "users": "Utenti",
"bans": "Ban", "bans": "Ban",
@@ -670,6 +674,8 @@
"applications": "Candidature", "applications": "Candidature",
"teams": "Team", "teams": "Team",
"permissions": "Permessi", "permissions": "Permessi",
"onlineUsers": "Utenti online",
"moderation": "Moderazione",
"economy": "Economia", "economy": "Economia",
"catalog": "Catalogo", "catalog": "Catalogo",
"shop": "Negozio", "shop": "Negozio",
@@ -696,6 +702,9 @@
"emulator": "Emulatore", "emulator": "Emulatore",
"alerts": "Avvisi", "alerts": "Avvisi",
"logs": "Registri", "logs": "Registri",
"auditLog": "Log di audit",
"analytics": "Analitiche",
"devops": "DevOps",
"wordFilter": "Filtro parole", "wordFilter": "Filtro parole",
"maintenance": "Manutenzione", "maintenance": "Manutenzione",
"housekeeping": "Housekeeping", "housekeeping": "Housekeeping",
+21 -12
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Beschrijfbare boxes", "writeableBoxes": "Beschrijfbare boxes",
"photos": "Foto's", "photos": "Foto's",
"tickets": "Tickets", "tickets": "Tickets",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Gebruikers & toegang", "usersAndAccess": "Gebruikers & toegang",
"users": "Gebruikers", "users": "Gebruikers",
"bans": "Bans", "bans": "Bans",
@@ -706,6 +710,8 @@
"applications": "Sollicitaties", "applications": "Sollicitaties",
"teams": "Teams", "teams": "Teams",
"permissions": "Permissies", "permissions": "Permissies",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Economie", "economy": "Economie",
"catalog": "Catalogus", "catalog": "Catalogus",
"shop": "Winkel", "shop": "Winkel",
@@ -732,9 +738,11 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Meldingen", "alerts": "Meldingen",
"logs": "Logboeken", "logs": "Logboeken",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Woordfilter", "wordFilter": "Woordfilter",
"maintenance": "Onderhoud", "maintenance": "Onderhoud",
"housekeeping": "Housekeeping",
"rooms": "Kamers", "rooms": "Kamers",
"emailTemplates": "E-mailsjablonen", "emailTemplates": "E-mailsjablonen",
"theme": "Thema", "theme": "Thema",
@@ -745,6 +753,7 @@
"import": "Importeren", "import": "Importeren",
"settings": "Instellingen", "settings": "Instellingen",
"backToSite": "Terug naar site", "backToSite": "Terug naar site",
"housekeeping": "Housekeeping",
"hotelManagementPanel": "Hotelbeheerpaneel", "hotelManagementPanel": "Hotelbeheerpaneel",
"rankLabel": "Rang {rank}" "rankLabel": "Rang {rank}"
}, },
@@ -1504,7 +1513,7 @@
}, },
"embedPage": { "embedPage": {
"title": "Radio Embed", "title": "Radio Embed",
"subtitle": "Copy-ready embed code for the live stream. Read-only \u2014 nothing here writes to the database. Configure the stream URL in Radio Settings (radio_stream_url).", "subtitle": "Copy-ready embed code for the live stream. Read-only — nothing here writes to the database. Configure the stream URL in Radio Settings (radio_stream_url).",
"streamUrl": "Stream URL", "streamUrl": "Stream URL",
"configured": "Configured", "configured": "Configured",
"missing": "Missing", "missing": "Missing",
@@ -1521,7 +1530,7 @@
"audioSnippet": "Audio player snippet", "audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.", "audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
"iframeSnippet": "Iframe wrapper snippet", "iframeSnippet": "Iframe wrapper snippet",
"iframeSnippetDesc": "Embeds the site\u2019s own radio player page in an iframe \u2014 keeps your styling and now-playing info. Adjust width/height to taste.", "iframeSnippetDesc": "Embeds the site’s own radio player page in an iframe — keeps your styling and now-playing info. Adjust width/height to taste.",
"audioUnsupported": "Your browser does not support the audio element." "audioUnsupported": "Your browser does not support the audio element."
}, },
"banners": { "banners": {
@@ -1531,7 +1540,7 @@
"addBanner": "Banner toevoegen", "addBanner": "Banner toevoegen",
"createBanner": "Create banner", "createBanner": "Create banner",
"deleteBanner": "Banner verwijderen", "deleteBanner": "Banner verwijderen",
"noBanners": "No banners yet \u2014 add one above.", "noBanners": "No banners yet — add one above.",
"existing": "Existing banners ({count})", "existing": "Existing banners ({count})",
"save": "Save", "save": "Save",
"delete": "Delete", "delete": "Delete",
@@ -1562,7 +1571,7 @@
"description": "Description", "description": "Description",
"createRank": "Create rank", "createRank": "Create rank",
"existing": "Existing ranks ({count})", "existing": "Existing ranks ({count})",
"noRanks": "No ranks yet \u2014 add one above.", "noRanks": "No ranks yet — add one above.",
"activeLabel": "active", "activeLabel": "active",
"inactiveLabel": "inactive", "inactiveLabel": "inactive",
"save": "Save", "save": "Save",
@@ -1589,13 +1598,13 @@
}, },
"apiKeysForm": { "apiKeysForm": {
"title": "API-sleutels", "title": "API-sleutels",
"subtitle": "API keys for external radio integrations (radio_api_keys). Keys are generated server-side and shown masked \u2014 copy the full value from the database when first issued.", "subtitle": "API keys for external radio integrations (radio_api_keys). Keys are generated server-side and shown masked — copy the full value from the database when first issued.",
"dbError": "Could not load API keys (database unavailable).", "dbError": "Could not load API keys (database unavailable).",
"totalKeys": "Total keys", "totalKeys": "Total keys",
"active": "Active", "active": "Active",
"inactive": "Inactive", "inactive": "Inactive",
"generateKey": "Nieuwe sleutel genereren", "generateKey": "Nieuwe sleutel genereren",
"generateHint": "The secret key is generated automatically with cryptographic randomness \u2014 you do not enter it.", "generateHint": "The secret key is generated automatically with cryptographic randomness — you do not enter it.",
"name": "Name", "name": "Name",
"namePlaceholder": "AzureCast bridge", "namePlaceholder": "AzureCast bridge",
"rateLimit": "Rate limit (requests/min)", "rateLimit": "Rate limit (requests/min)",
@@ -1603,7 +1612,7 @@
"allowedIpsPlaceholder": "comma-separated, blank = any", "allowedIpsPlaceholder": "comma-separated, blank = any",
"generate": "Genereren", "generate": "Genereren",
"existingKeys": "Existing keys ({count})", "existingKeys": "Existing keys ({count})",
"noKeys": "No API keys yet \u2014 generate one above.", "noKeys": "No API keys yet — generate one above.",
"colName": "Name", "colName": "Name",
"colKey": "Sleutel", "colKey": "Sleutel",
"colAllowedIps": "Allowed IPs", "colAllowedIps": "Allowed IPs",
@@ -1656,12 +1665,12 @@
"artist": "Artist", "artist": "Artist",
"album": "Album", "album": "Album",
"artworkUrl": "Artwork URL", "artworkUrl": "Artwork URL",
"artworkUrlPlaceholder": "https://\u2026/cover.png", "artworkUrlPlaceholder": "https://…/cover.png",
"duration": "Duration (seconds)", "duration": "Duration (seconds)",
"durationPlaceholder": "e.g. 215", "durationPlaceholder": "e.g. 215",
"sortOrder": "Sort order", "sortOrder": "Sort order",
"playlist": "Playlist ({count})", "playlist": "Playlist ({count})",
"noTracks": "No AutoDJ tracks yet \u2014 add one above.", "noTracks": "No AutoDJ tracks yet — add one above.",
"colOrder": "Order", "colOrder": "Order",
"colTitle": "Title", "colTitle": "Title",
"colArtist": "Artist", "colArtist": "Artist",
@@ -1679,7 +1688,7 @@
}, },
"monitoring": { "monitoring": {
"title": "Radiomonitoring", "title": "Radiomonitoring",
"subtitle": "Read-only live probe of the configured radio endpoints (radio_now_playing_api_url, radio_listeners_api_url). Each request fails soft with a {timeout}s timeout \u2014 no data is written. Configure URLs under Settings.", "subtitle": "Read-only live probe of the configured radio endpoints (radio_now_playing_api_url, radio_listeners_api_url). Each request fails soft with a {timeout}s timeout — no data is written. Configure URLs under Settings.",
"stream": "Stream", "stream": "Stream",
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
@@ -1694,7 +1703,7 @@
"noNowPlayingHint": "no now-playing API set", "noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set", "noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics", "endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable \u00b7 <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured \u00b7 <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.", "diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"streamUrl": "Stream URL", "streamUrl": "Stream URL",
"unreachable": "unreachable", "unreachable": "unreachable",
"refresh": "Vernieuwen" "refresh": "Vernieuwen"
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Skrivbare bokser", "writeableBoxes": "Skrivbare bokser",
"photos": "Bilder", "photos": "Bilder",
"tickets": "Billetter", "tickets": "Billetter",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Brukere og tilgang", "usersAndAccess": "Brukere og tilgang",
"users": "Brukere", "users": "Brukere",
"bans": "Forbud", "bans": "Forbud",
@@ -706,6 +710,8 @@
"applications": "Søknader", "applications": "Søknader",
"teams": "Lag", "teams": "Lag",
"permissions": "Tillatelser", "permissions": "Tillatelser",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Økonomi", "economy": "Økonomi",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Handle", "shop": "Handle",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Varsler", "alerts": "Varsler",
"logs": "Logger", "logs": "Logger",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Ordfilter", "wordFilter": "Ordfilter",
"maintenance": "Vedlikehold", "maintenance": "Vedlikehold",
"rooms": "Rom", "rooms": "Rom",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Pudełka z możliwością zapisu", "writeableBoxes": "Pudełka z możliwością zapisu",
"photos": "Zdjęcia", "photos": "Zdjęcia",
"tickets": "Bilety", "tickets": "Bilety",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Użytkownicy i dostęp", "usersAndAccess": "Użytkownicy i dostęp",
"users": "Użytkownicy", "users": "Użytkownicy",
"bans": "Zakazy", "bans": "Zakazy",
@@ -706,6 +710,8 @@
"applications": "Aplikacje", "applications": "Aplikacje",
"teams": "Zespoły", "teams": "Zespoły",
"permissions": "Uprawnienia", "permissions": "Uprawnienia",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Ekonomia", "economy": "Ekonomia",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Sklep", "shop": "Sklep",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Alerty", "alerts": "Alerty",
"logs": "Dzienniki", "logs": "Dzienniki",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtr słów", "wordFilter": "Filtr słów",
"maintenance": "Konserwacja", "maintenance": "Konserwacja",
"rooms": "Pokoje", "rooms": "Pokoje",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Caixas graváveis", "writeableBoxes": "Caixas graváveis",
"photos": "Fotos", "photos": "Fotos",
"tickets": "Ingressos", "tickets": "Ingressos",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Usuários e acesso", "usersAndAccess": "Usuários e acesso",
"users": "Usuários", "users": "Usuários",
"bans": "Proibições", "bans": "Proibições",
@@ -706,6 +710,8 @@
"applications": "Aplicativos", "applications": "Aplicativos",
"teams": "Equipes", "teams": "Equipes",
"permissions": "Permissões", "permissions": "Permissões",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Economia", "economy": "Economia",
"catalog": "Catálogo", "catalog": "Catálogo",
"shop": "Loja", "shop": "Loja",
@@ -732,6 +738,9 @@
"emulator": "Emulador", "emulator": "Emulador",
"alerts": "Alertas", "alerts": "Alertas",
"logs": "Registros", "logs": "Registros",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtro de palavras", "wordFilter": "Filtro de palavras",
"maintenance": "Manutenção", "maintenance": "Manutenção",
"rooms": "Quartos", "rooms": "Quartos",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Cutii care pot fi scrise", "writeableBoxes": "Cutii care pot fi scrise",
"photos": "Fotografii", "photos": "Fotografii",
"tickets": "bilete", "tickets": "bilete",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Utilizatori și acces", "usersAndAccess": "Utilizatori și acces",
"users": "Utilizatori", "users": "Utilizatori",
"bans": "Interdicții", "bans": "Interdicții",
@@ -706,6 +710,8 @@
"applications": "Aplicații", "applications": "Aplicații",
"teams": "Echipe", "teams": "Echipe",
"permissions": "Permisiuni", "permissions": "Permisiuni",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Economie", "economy": "Economie",
"catalog": "Catalog", "catalog": "Catalog",
"shop": "Magazin", "shop": "Magazin",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Alerte", "alerts": "Alerte",
"logs": "Bușteni", "logs": "Bușteni",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filtru de cuvinte", "wordFilter": "Filtru de cuvinte",
"maintenance": "Întreținere", "maintenance": "Întreținere",
"rooms": "Camerele", "rooms": "Camerele",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Ящики для записи", "writeableBoxes": "Ящики для записи",
"photos": "Фото", "photos": "Фото",
"tickets": "Билеты", "tickets": "Билеты",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Пользователи и доступ", "usersAndAccess": "Пользователи и доступ",
"users": "Пользователи", "users": "Пользователи",
"bans": "Запреты", "bans": "Запреты",
@@ -706,6 +710,8 @@
"applications": "Области применения", "applications": "Области применения",
"teams": "Команды", "teams": "Команды",
"permissions": "Разрешения", "permissions": "Разрешения",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Экономика", "economy": "Экономика",
"catalog": "Каталог", "catalog": "Каталог",
"shop": "Магазин", "shop": "Магазин",
@@ -732,6 +738,9 @@
"emulator": "Эмулятор", "emulator": "Эмулятор",
"alerts": "Оповещения", "alerts": "Оповещения",
"logs": "Журналы", "logs": "Журналы",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Фильтр слов", "wordFilter": "Фильтр слов",
"maintenance": "Техническое обслуживание", "maintenance": "Техническое обслуживание",
"rooms": "Номера", "rooms": "Номера",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Popisovateľné boxy", "writeableBoxes": "Popisovateľné boxy",
"photos": "Fotografie", "photos": "Fotografie",
"tickets": "Vstupenky", "tickets": "Vstupenky",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Používatelia a prístup", "usersAndAccess": "Používatelia a prístup",
"users": "Používatelia", "users": "Používatelia",
"bans": "Zákazy", "bans": "Zákazy",
@@ -706,6 +710,8 @@
"applications": "Aplikácie", "applications": "Aplikácie",
"teams": "Tímy", "teams": "Tímy",
"permissions": "Povolenia", "permissions": "Povolenia",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "ekonomika", "economy": "ekonomika",
"catalog": "Katalóg", "catalog": "Katalóg",
"shop": "Obchod", "shop": "Obchod",
@@ -732,6 +738,9 @@
"emulator": "Emulátor", "emulator": "Emulátor",
"alerts": "Upozornenia", "alerts": "Upozornenia",
"logs": "Denníky", "logs": "Denníky",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Filter slov", "wordFilter": "Filter slov",
"maintenance": "Údržba", "maintenance": "Údržba",
"rooms": "Izby", "rooms": "Izby",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Кутије за писање", "writeableBoxes": "Кутије за писање",
"photos": "Фотографије", "photos": "Фотографије",
"tickets": "Улазнице", "tickets": "Улазнице",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Корисници и приступ", "usersAndAccess": "Корисници и приступ",
"users": "Корисници", "users": "Корисници",
"bans": "Банс", "bans": "Банс",
@@ -706,6 +710,8 @@
"applications": "Апликације", "applications": "Апликације",
"teams": "Тимови", "teams": "Тимови",
"permissions": "Дозволе", "permissions": "Дозволе",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Економија", "economy": "Економија",
"catalog": "Каталог", "catalog": "Каталог",
"shop": "Схоп", "shop": "Схоп",
@@ -732,6 +738,9 @@
"emulator": "Емулатор", "emulator": "Емулатор",
"alerts": "Алертс", "alerts": "Алертс",
"logs": "Дневници", "logs": "Дневници",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Филтер речи", "wordFilter": "Филтер речи",
"maintenance": "Одржавање", "maintenance": "Одржавање",
"rooms": "Собе", "rooms": "Собе",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Skrivbara lådor", "writeableBoxes": "Skrivbara lådor",
"photos": "Foton", "photos": "Foton",
"tickets": "Biljetter", "tickets": "Biljetter",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Användare och åtkomst", "usersAndAccess": "Användare och åtkomst",
"users": "Användare", "users": "Användare",
"bans": "Förbud", "bans": "Förbud",
@@ -706,6 +710,8 @@
"applications": "Ansökningar", "applications": "Ansökningar",
"teams": "Lag", "teams": "Lag",
"permissions": "Behörigheter", "permissions": "Behörigheter",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Ekonomi", "economy": "Ekonomi",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Handla", "shop": "Handla",
@@ -732,6 +738,9 @@
"emulator": "Emulator", "emulator": "Emulator",
"alerts": "Varningar", "alerts": "Varningar",
"logs": "Loggar", "logs": "Loggar",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Ordfilter", "wordFilter": "Ordfilter",
"maintenance": "Underhåll", "maintenance": "Underhåll",
"rooms": "Rum", "rooms": "Rum",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Yazılabilir kutular", "writeableBoxes": "Yazılabilir kutular",
"photos": "Fotoğraflar", "photos": "Fotoğraflar",
"tickets": "Biletler", "tickets": "Biletler",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Kullanıcılar ve erişim", "usersAndAccess": "Kullanıcılar ve erişim",
"users": "Kullanıcılar", "users": "Kullanıcılar",
"bans": "Yasaklar", "bans": "Yasaklar",
@@ -706,6 +710,8 @@
"applications": "Uygulamalar", "applications": "Uygulamalar",
"teams": "Takımlar", "teams": "Takımlar",
"permissions": "İzinler", "permissions": "İzinler",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Ekonomi", "economy": "Ekonomi",
"catalog": "Katalog", "catalog": "Katalog",
"shop": "Mağaza", "shop": "Mağaza",
@@ -732,6 +738,9 @@
"emulator": "Emülatör", "emulator": "Emülatör",
"alerts": "Uyarılar", "alerts": "Uyarılar",
"logs": "Günlükler", "logs": "Günlükler",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Kelime filtresi", "wordFilter": "Kelime filtresi",
"maintenance": "Bakım", "maintenance": "Bakım",
"rooms": "Odalar", "rooms": "Odalar",
+9
View File
@@ -698,6 +698,10 @@
"writeableBoxes": "Ящики для запису", "writeableBoxes": "Ящики для запису",
"photos": "Фотографії", "photos": "Фотографії",
"tickets": "квитки", "tickets": "квитки",
"events": "Events",
"polls": "Polls",
"banners": "Banners",
"prefixes": "Prefixes",
"usersAndAccess": "Користувачі та доступ", "usersAndAccess": "Користувачі та доступ",
"users": "Користувачі", "users": "Користувачі",
"bans": "Заборони", "bans": "Заборони",
@@ -706,6 +710,8 @@
"applications": "Додатки", "applications": "Додатки",
"teams": "Команди", "teams": "Команди",
"permissions": "Дозволи", "permissions": "Дозволи",
"onlineUsers": "Online users",
"moderation": "Moderation",
"economy": "Економіка", "economy": "Економіка",
"catalog": "Каталог", "catalog": "Каталог",
"shop": "Магазин", "shop": "Магазин",
@@ -732,6 +738,9 @@
"emulator": "Емулятор", "emulator": "Емулятор",
"alerts": "Оповіщення", "alerts": "Оповіщення",
"logs": "Журнали", "logs": "Журнали",
"auditLog": "Audit log",
"analytics": "Analytics",
"devops": "DevOps",
"wordFilter": "Фільтр Word", "wordFilter": "Фільтр Word",
"maintenance": "Технічне обслуговування", "maintenance": "Технічне обслуговування",
"rooms": "Кімнати", "rooms": "Кімнати",