fix(i18n): persist CMS translations and validate message catalogs
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m27s

This commit is contained in:
Simo committed 2026-09-06 17:55:11 +02:00
1 parent 96234075f8
commit ef94646d60
37 files changed
+1621 -463

No files matched your search

+25 -38
View File
@@ -3,60 +3,47 @@
import fs from "node:fs/promises";
import path from "node:path";
import * as JSONC from "jsonc-parser";
import { revalidatePath } from "next/cache";
import { getTranslations } from "next-intl/server";
import { z } from "zod";
import { SUPPORTED_LOCALES } from "@/i18n/locales";
import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import {
CmsTranslationError,
saveCmsTranslation,
} from "@/lib/cms-translations";
import { patchJson5 } from "@/lib/json5-patch";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({
locale: z.enum([
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
]),
data: z.record(z.string(), z.unknown()),
locale: z.enum(SUPPORTED_LOCALES),
revision: z.string().regex(/^[a-f0-9]{64}$/),
changes: z
.record(z.string().max(300), z.string().max(20000).nullable())
.refine((value) => Object.keys(value).length <= 5000),
});
export const saveTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => {
const filePath = path.join(
process.cwd(),
"src",
"messages",
`${ctx.data.locale}.json`,
);
await fs.writeFile(
filePath,
JSON.stringify(ctx.data.data, null, 2),
"utf-8",
);
return actionOk();
try {
const snapshot = await saveCmsTranslation(
ctx.data.locale,
ctx.data.revision,
ctx.data.changes,
);
revalidatePath("/", "layout");
return actionOk({ snapshot });
} catch (error) {
if (!(error instanceof CmsTranslationError)) throw error;
const t = await getTranslations("pages.admin.translations.cms");
return { ok: false, error: t(error.code, { key: error.key }) };
}
},
);
+1 -1
View File
@@ -18,7 +18,7 @@ export default function AdminErrorPage({
reset: () => void;
}) {
const t = useTranslations("pages.error");
const tNav = useTranslations("admin.nav");
const tNav = useTranslations("pages.admin.nav");
const [reference, setReference] = useState(error.digest);
useEffect(() => {
+19 -43
View File
@@ -1,48 +1,24 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getLocale } from "next-intl/server";
import { SUPPORTED_LOCALES } from "@/i18n/locales";
import { readCmsTranslation } from "@/lib/cms-translations";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { TranslationsClient } from "../translations-client";
const LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export default async function CmsTranslationsPage() {
const messagesDir = path.join(process.cwd(), "src", "messages");
const contents: Record<string, string> = {};
await Promise.all(
LOCALES.map(async (locale) => {
try {
contents[locale] = await fs.readFile(
path.join(messagesDir, `${locale}.json`),
"utf-8",
);
} catch {
// Skip missing locale files instead of failing the whole page.
}
}),
const [{ session, permissions }, locale, entries] = await Promise.all([
getAdminContext(),
getLocale(),
Promise.all(
SUPPORTED_LOCALES.map(
async (locale) => [locale, await readCmsTranslation(locale)] as const,
),
),
]);
return (
<TranslationsClient
snapshots={Object.fromEntries(entries)}
initialLocale={locale}
canEdit={canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)}
/>
);
return <TranslationsClient localeContents={contents} />;
}
+271 -169
View File
@@ -1,132 +1,127 @@
"use client";
import { Download, Save } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
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";
type Locale =
| "en"
| "it"
| "nl"
| "de"
| "fr"
| "es"
| "pt"
| "pl"
| "sv"
| "tr"
| "ro"
| "hu"
| "cs"
| "sk"
| "da"
| "no"
| "el"
| "bg"
| "hr"
| "sr"
| "uk"
| "ru";
const LOCALES: { code: Locale; label: string }[] = [
{ code: "en", label: "EN" },
{ code: "it", label: "IT" },
{ code: "nl", label: "NL" },
{ code: "de", label: "DE" },
{ code: "fr", label: "FR" },
{ code: "es", label: "ES" },
{ code: "pt", label: "PT" },
{ code: "pl", label: "PL" },
{ code: "sv", label: "SV" },
{ code: "tr", label: "TR" },
{ code: "ro", label: "RO" },
{ code: "hu", label: "HU" },
{ code: "cs", label: "CS" },
{ code: "sk", label: "SK" },
{ code: "da", label: "DA" },
{ code: "no", label: "NO" },
{ code: "el", label: "EL" },
{ code: "bg", label: "BG" },
{ code: "hr", label: "HR" },
{ code: "sr", label: "SR" },
{ code: "uk", label: "UK" },
{ code: "ru", label: "RU" },
];
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import {
type AppLocale,
isSupportedLocale,
SUPPORTED_LOCALES,
} from "@/i18n/locales";
import { validateCmsMessage } from "@/lib/cms-message-validation";
import type { CmsTranslationSnapshot } from "@/lib/cms-translations";
import { unflattenObject } from "@/lib/translations-utils";
interface Props {
localeContents: Record<string, string>;
snapshots: Record<string, CmsTranslationSnapshot>;
initialLocale: string;
canEdit: boolean;
}
export function TranslationsClient({ localeContents }: Props) {
const [data, setData] = useState<Record<string, Record<string, string>>>(
() => {
const initial: Record<string, Record<string, string>> = {};
for (const [locale, content] of Object.entries(localeContents)) {
initial[locale] = flattenObject(JSON.parse(content));
}
return initial;
},
const PER_PAGE = 30;
export function TranslationsClient({
snapshots,
initialLocale,
canEdit,
}: Props) {
const t = useTranslations("pages.admin.translations.cms");
const router = useRouter();
const [saved, setSaved] = useState(snapshots);
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>(
{},
);
const [activeLang, setActiveLang] = useState<AppLocale>(
isSupportedLocale(initialLocale) ? initialLocale : "en",
);
const [search, setSearch] = useState("");
const [group, setGroup] = useState("");
const [activeLang, setActiveLang] = useState<Locale>("en");
const { run, isPending } = useServerAction();
const PER_PAGE = 30;
const [status, setStatus] = useState("all");
const [page, setPage] = useState(1);
const activeData = data[activeLang];
const groups = useMemo(() => {
const g = new Set<string>();
Object.keys(data.en ?? {}).forEach((k) => {
const parts = k.split(".");
if (parts.length > 1) g.add(parts[0]);
});
return Array.from(g).sort();
}, [data]);
const filteredKeys = useMemo(() => {
if (!activeData) return [];
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;
const { run, isPending } = useServerAction();
const source = saved.en.messages;
const activeData = saved[activeLang].messages;
const draft = drafts[activeLang] ?? {};
const keys = useMemo(() => Object.keys(source).sort(), [source]);
const groups = useMemo(
() => [...new Set(keys.map((key) => key.split(".")[0]))],
[keys],
);
const dirty = Object.values(drafts).some(
(values) => Object.keys(values).length > 0,
);
useUnsavedChanges(dirty, t("leaveWarning"));
const valueFor = (key: string) => draft[key] ?? activeData[key] ?? "";
const missing = keys.filter((key) => !valueFor(key).trim()).length;
const identical =
activeLang === "en"
? 0
: keys.filter((key) => valueFor(key) === source[key]).length;
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(draft)) {
const issue = validateCmsMessage(value, source[key]);
if (issue) errors[key] = t(issue, { key });
}
const filteredKeys = keys.filter((key) => {
const value = valueFor(key);
const matchesSearch = [key, value, source[key]].some((text) =>
text.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
);
const matchesStatus =
status === "all" ||
(status === "missing" && !activeData[key]?.trim()) ||
(status === "identical" &&
activeLang !== "en" &&
activeData[key] === source[key]) ||
(status === "modified" && Object.hasOwn(draft, key));
return (
matchesSearch && matchesStatus && (!group || key.startsWith(`${group}.`))
);
});
const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE));
const currentPage = Math.min(page, totalPages);
const pageKeys = filteredKeys.slice(
(currentPage - 1) * PER_PAGE,
currentPage * PER_PAGE,
);
function updateValue(key: string, value: string) {
setDrafts((current) => {
const next = { ...current[activeLang] };
if (value === (activeData[key] ?? "")) delete next[key];
else next[key] = value;
return { ...current, [activeLang]: next };
});
}, [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) {
setData({ ...data, [activeLang]: { ...activeData, [key]: value } });
}
function handleSave() {
if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length)
return;
const locale = activeLang;
run(
() =>
saveTranslations({
locale: activeLang,
data: unflattenObject(activeData),
locale,
revision: saved[locale].revision,
changes: draft,
}),
{
successMessage: "Translations saved!",
successMessage: t("saved"),
onSuccess: (result) => {
const snapshot = result.snapshot as CmsTranslationSnapshot;
setSaved((current) => ({ ...current, [locale]: snapshot }));
setDrafts((current) => ({ ...current, [locale]: {} }));
router.refresh();
},
},
);
}
function handleExport() {
const blob = new Blob(
[JSON.stringify(unflattenObject(activeData), null, 2)],
{
type: "application/json",
},
[JSON.stringify(unflattenObject({ ...activeData, ...draft }), null, 2)],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -135,106 +130,213 @@ export function TranslationsClient({ localeContents }: Props) {
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<div className="flex justify-end">
<div className="flex gap-2">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">{t("title")}</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{t("description")}
</p>
</div>
<div className="flex flex-wrap 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"}
<Download className="mr-1 h-4 w-4" />
{t("export")}
</Button>
{canEdit && (
<Button
size="sm"
onClick={handleSave}
disabled={
isPending ||
!Object.keys(draft).length ||
!!Object.keys(errors).length
}
>
<Save className="mr-1 h-4 w-4" />
{isPending
? t("saving")
: t("saveLocale", { locale: activeLang.toUpperCase() })}
</Button>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-1 overflow-x-auto pb-1 max-w-full">
{LOCALES.map(({ code, label }) => (
{!canEdit && (
<p className="text-sm text-muted-foreground">{t("readOnly")}</p>
)}
<fieldset disabled={isPending} className="min-w-0 space-y-4">
<fieldset
className="flex min-w-0 flex-wrap gap-1"
aria-label={t("language")}
>
{SUPPORTED_LOCALES.map((code) => (
<Button
key={code}
variant={activeLang === code ? "default" : "outline"}
size="sm"
className="shrink-0"
variant={activeLang === code ? "default" : "outline"}
aria-pressed={activeLang === code}
onClick={() => {
setActiveLang(code);
setPage(1);
}}
>
{label}
{code.toUpperCase()}
{Object.keys(drafts[code] ?? {}).length ? " *" : ""}
</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>
</fieldset>
<p className="text-sm" role="status">
{t("coverage", { total: keys.length, missing, identical })}
</p>
<div className="flex flex-wrap items-end gap-3">
<label
htmlFor="cms-translation-search"
className="grid w-full min-w-0 gap-1 text-sm md:w-auto md:flex-1"
>
{t("search")}
<Input
value={activeData[key]}
onChange={(e) => updateValue(key, e.target.value)}
className="flex-1 h-8 text-sm"
id="cms-translation-search"
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t("searchPlaceholder")}
/>
</label>
<label className="grid gap-1 text-sm">
{t("group")}
<select
value={group}
onChange={(e) => {
setGroup(e.target.value);
setPage(1);
}}
className="rounded-md border bg-background px-3 py-2"
>
<option value="">{t("allGroups")}</option>
{groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm">
{t("status")}
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
className="rounded-md border bg-background px-3 py-2"
>
<option value="all">{t("all")}</option>
<option value="missing">{t("missing")}</option>
{activeLang !== "en" && (
<option value="identical">{t("identical")}</option>
)}
<option value="modified">{t("modified")}</option>
</select>
</label>
</div>
<p className="text-xs text-muted-foreground">{t("fallbackHint")}</p>
{Object.keys(draft).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm">
{t("unsaved", { count: Object.keys(draft).length })}
</span>
<Button
size="sm"
variant="outline"
onClick={() => {
if (window.confirm(t("discardConfirm")))
setDrafts((current) => ({ ...current, [activeLang]: {} }));
}}
>
{t("discard")}
</Button>
{Object.keys(errors).length > 0 && (
<span role="alert" className="text-sm text-destructive">
{t("fixErrors", { count: Object.keys(errors).length })}
</span>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
)}
<div className="divide-y rounded-lg border bg-card">
{pageKeys.length === 0 && (
<p className="p-6 text-sm text-muted-foreground">
{t("noResults")}
</p>
)}
{pageKeys.map((key) => (
<div key={key} className="grid min-w-0 gap-2 p-4">
<label
htmlFor={`translation-${key}`}
className="break-all font-mono text-xs text-muted-foreground"
>
{key}
</label>
{activeLang !== "en" && (
<p className="whitespace-pre-wrap break-words text-sm">
<span className="font-medium">{t("source")}: </span>
{source[key]}
</p>
)}
<textarea
id={`translation-${key}`}
aria-invalid={!!errors[key]}
aria-describedby={errors[key] ? `error-${key}` : undefined}
readOnly={!canEdit}
value={valueFor(key)}
onChange={(e) => updateValue(key, e.target.value)}
placeholder={t("missingPlaceholder")}
rows={2}
className="w-full min-w-0 resize-y rounded-md border bg-background px-3 py-2 text-sm"
/>
{errors[key] && (
<p
id={`error-${key}`}
role="alert"
className="break-words text-sm text-destructive"
>
{errors[key]}
</p>
)}
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("page", {
page: currentPage,
total: totalPages,
count: filteredKeys.length,
})}
</span>
<div className="flex gap-1">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
disabled={currentPage <= 1}
onClick={() => setPage(currentPage - 1)}
>
Prev
{t("previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
disabled={currentPage >= totalPages}
onClick={() => setPage(currentPage + 1)}
>
Next
{t("next")}
</Button>
</div>
</div>
)}
</fieldset>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { readdir, readFile } from "node:fs/promises";
import { parse } from "@formatjs/icu-messageformat-parser";
import { describe, expect, it } from "vitest";
import { flattenObject } from "@/lib/translations-utils";
describe("CMS message catalogs", () => {
it("contains valid ICU messages in every shipped locale", async () => {
const errors: string[] = [];
for (const file of await readdir("src/messages")) {
if (!file.endsWith(".json")) continue;
const messages = flattenObject(
JSON.parse(await readFile(`src/messages/${file}`, "utf8")),
);
for (const [key, value] of Object.entries(messages)) {
try {
parse(value);
} catch {
errors.push(`${file}: ${key}`);
}
}
}
expect(errors).toEqual([]);
});
it("provides actual text for the search result headings and dashboard counts", async () => {
const { createTranslator } = await import("next-intl");
const messages = JSON.parse(await readFile("src/messages/en.json", "utf8"));
const errors: unknown[] = [];
const t = createTranslator({
locale: "en",
messages,
onError: (error) => errors.push(error),
});
expect(t("pages.admin.dashboard.totalAccounts", { count: 12 })).toContain(
"12",
);
expect(t("pages.admin.search.noResults")).not.toContain("pages.");
expect(errors).toEqual([]);
});
});
+32
View File
@@ -0,0 +1,32 @@
export const SUPPORTED_LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
export function isSupportedLocale(
value: string | undefined,
): value is AppLocale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
+12 -39
View File
@@ -1,41 +1,17 @@
import { cookies, headers } from "next/headers";
import { getRequestConfig } from "next-intl/server";
import { loadCmsMessages } from "@/lib/cms-translations";
import { mergeMessages } from "./merge-messages";
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
// the Accept-Language header, and finally English as fallback.
export const SUPPORTED_LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
export {
DEFAULT_LOCALE,
isSupportedLocale,
SUPPORTED_LOCALES,
} from "./locales";
export function isSupportedLocale(
value: string | undefined,
): value is AppLocale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
import { type AppLocale, DEFAULT_LOCALE, isSupportedLocale } from "./locales";
function detectLocaleFromAcceptLanguage(
acceptLanguage: string | null,
@@ -73,14 +49,10 @@ export default getRequestConfig(async () => {
locale = detected ?? DEFAULT_LOCALE;
}
// Safe: `locale` is validated against SUPPORTED_LOCALES above (only en/it/nl/de/fr/es).
const messages = (await import(`../messages/${locale}.json`)).default;
// English is the source of truth; fall back to it for any key missing from a
// translation so the UI never shows a raw key path.
const fallback =
locale === DEFAULT_LOCALE
? messages
: (await import(`../messages/${DEFAULT_LOCALE}.json`)).default;
const [messages, fallback] = await Promise.all([
loadCmsMessages(locale),
loadCmsMessages(DEFAULT_LOCALE),
]);
return {
locale,
@@ -108,6 +80,7 @@ export default getRequestConfig(async () => {
if (typeof fromEnglish === "string") return fromEnglish;
const seg = key.split(".").pop() ?? key;
return seg
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[._-]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
+36
View File
@@ -0,0 +1,36 @@
import {
type MessageFormatElement,
parse,
TYPE,
} from "@formatjs/icu-messageformat-parser";
/** Argument names and rich-text tags are a contract with the calling component. */
export function messageVariables(value: string): string[] {
const names = new Set<string>();
function visit(nodes: MessageFormatElement[]) {
for (const node of nodes) {
if (node.type !== TYPE.literal && node.type !== TYPE.pound)
names.add(node.value);
if (node.type === TYPE.select || node.type === TYPE.plural) {
for (const option of Object.values(node.options)) visit(option.value);
}
if (node.type === TYPE.tag) visit(node.children);
}
}
visit(parse(value));
return [...names].sort();
}
export function validateCmsMessage(
value: string,
source: string,
): "empty" | "invalid" | "variables" | null {
if (!value.trim()) return "empty";
try {
return messageVariables(value).join("\0") ===
messageVariables(source).join("\0")
? null
: "variables";
} catch {
return "invalid";
}
}
+108
View File
@@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readCmsTranslation, saveCmsTranslation } from "./cms-translations";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((dir) => rm(dir, { recursive: true, force: true })),
);
});
async function root() {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-translations-test-"));
directories.push(dir);
return dir;
}
describe("CMS translation persistence", () => {
it("reads bundled messages without source files and persists only overrides", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
expect(before.messages["pages.admin.search.users"]).toBe("Utenti");
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
expect(
(await readCmsTranslation("it", dir)).messages[
"pages.admin.search.users"
],
).toBe("Persone");
expect(
JSON.parse(await readFile(path.join(dir, "it.json"), "utf8")),
).toEqual({ "pages.admin.search.users": "Persone" });
});
it("rejects stale saves and invalid ICU without overwriting saved text", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Stale" },
dir,
),
).rejects.toThrow("conflict");
const current = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "{broken" },
dir,
),
).rejects.toThrow("invalid");
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "No variable" },
dir,
),
).rejects.toThrow("variables");
expect((await readCmsTranslation("it", dir)).revision).toBe(
current.revision,
);
});
it("restores the bundled value when an override is reset", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
const saved = await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
const reset = await saveCmsTranslation(
"it",
saved.revision,
{ "pages.admin.search.users": null },
dir,
);
expect(reset.messages["pages.admin.search.users"]).toBe("Utenti");
});
it("rejects unknown message keys and does not mutate prototypes", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "__proto__.polluted": "yes" },
dir,
),
).rejects.toThrow("unknownKey");
expect(Object.prototype).not.toHaveProperty("polluted");
});
});
+133
View File
@@ -0,0 +1,133 @@
import "server-only";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { cache } from "react";
import { type AppLocale, isSupportedLocale } from "@/i18n/locales";
import type { MessageTree } from "@/i18n/merge-messages";
import { validateCmsMessage } from "./cms-message-validation";
import { logger } from "./logger";
import {
withGamedataLock,
writeGamedataJsonAtomic,
} from "./services/import/core/gamedata-json";
import { flattenObject, unflattenObject } from "./translations-utils";
const defaultRoot = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"storage",
"cms-translations",
);
export interface CmsTranslationSnapshot {
messages: Record<string, string>;
revision: string;
}
export class CmsTranslationError extends Error {
constructor(
public readonly code:
| "conflict"
| "unknownKey"
| "empty"
| "invalid"
| "variables",
public readonly key = "",
) {
super(`${code}: ${key}`);
}
}
async function bundled(locale: AppLocale): Promise<Record<string, string>> {
return flattenObject((await import(`../messages/${locale}.json`)).default);
}
async function readOverrides(file: string): Promise<Record<string, string>> {
try {
const parsed: unknown = JSON.parse(
await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"),
);
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
Object.values(parsed).some((v) => typeof v !== "string")
)
throw new Error("Invalid CMS translation override file");
return parsed as Record<string, string>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
}
function localeFile(locale: AppLocale, root: string) {
if (!isSupportedLocale(locale)) throw new Error("Unsupported locale");
return path.join(root, `${locale}.json`);
}
export async function readCmsTranslation(
locale: AppLocale,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(localeFile(locale, root)),
]);
const messages = { ...base };
for (const [key, value] of Object.entries(overrides)) {
// Obsolete or incompatible overrides must not break new releases.
if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key]))
continue;
messages[key] = value;
}
return {
messages,
revision: createHash("sha256")
.update(JSON.stringify([base, overrides]))
.digest("hex"),
};
}
export async function saveCmsTranslation(
locale: AppLocale,
revision: string,
changes: Record<string, string | null>,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const file = localeFile(locale, root);
await fs.mkdir(root, { recursive: true });
return withGamedataLock(file, async () => {
const current = await readCmsTranslation(locale, root);
if (current.revision !== revision)
throw new CmsTranslationError("conflict");
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(file),
]);
for (const [key, value] of Object.entries(changes)) {
if (!Object.hasOwn(source, key))
throw new CmsTranslationError("unknownKey", key);
if (value === null || value === base[key]) {
delete overrides[key];
continue;
}
const issue = validateCmsMessage(value, source[key]);
if (issue) throw new CmsTranslationError(issue, key);
overrides[key] = value;
}
await writeGamedataJsonAtomic(file, overrides);
return readCmsTranslation(locale, root);
});
}
/** Per-request deduplication; runtime edits are visible on the next request. */
export const loadCmsMessages = cache(
async (locale: AppLocale): Promise<MessageTree> => {
try {
return unflattenObject(
(await readCmsTranslation(locale)).messages,
) as MessageTree;
} catch (error) {
logger.error("CMS translation overrides could not be loaded", {
locale,
error,
});
return (await import(`../messages/${locale}.json`)).default;
}
},
);
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "няма зададен API за игра в момента",
"noListenersHint": "няма зададен API за слушатели",
"endpointDiagnostics": "Диагностика на крайна точка",
"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)]\">ОК</span> достъпно · <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> не е конфигуриран · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ГРЕШКА</span> извличането е неуспешно / изтече времето за изчакване.",
"diagnosticsLegend": "OK: достъпно · WARN: не е конфигурирано · ERROR: неуспешна заявка или изтекло време.",
"streamUrl": "URL адрес на потока",
"unreachable": "недостижим",
"refresh": "Опресняване"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Няма зададен radio_stream_url, така че все още няма нищо за вграждане.",
"goToSettings": "Отидете на Радио настройки",
"livePreview": "Преглед на живо",
"livePreviewText": "Това е голият <audio> плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
"livePreviewText": "Това е голият audio плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
"source": "Източник",
"audioSnippet": "Фрагмент от аудио плейър",
"audioSnippetDesc": "Вмъкващ се HTML5 аудио плейър, насочен право към потока. Работи навсякъде, където е разрешен обикновен HTML.",
@@ -1998,7 +1998,7 @@
"title": "Сигнали",
"subtitle": "Изпращайте сигнали за целия хотел и вижте последните сигнали",
"sendAlert": "Изпратете сигнал за хотел",
"recentAlerts": "Последни сигнали",
"recentAlerts": "Последни сигнали ({count})",
"noRecentAlerts": "Няма скорошни сигнали",
"form": {
"title": "Заглавие на предупреждение",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Импортиране Разрешения",
"importHint": "Paste a JSON array of разрешение objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of разрешение objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Импортиране",
"exportTitle": "Export Разрешения",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Предварително зададени теми",
"presetsSubtitle": "Изберете предварително изградена цветова схема",
"applyPreset": "Кандидатствайте",
"presetApplied": "Предварителната настройка е приложена",
"presetApplied": "Предварителната настройка е приложена: {preset}",
"customiseTitle": "Персонализирани цветове",
"primary": "Първичен",
"secondary": "Вторичен",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "žádná nyní přehrávající sada API",
"noListenersHint": "není nastaveno žádné rozhraní API pro posluchače",
"endpointDiagnostics": "Diagnostika koncových bodů",
"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> dosažitelné · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-primaryl]-1 bg-8 rounded text-[var(--color-text)]\">WARN</span> není nakonfigurováno · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">CHYBA</span> se nezdařilo / vypršel časový limit.",
"diagnosticsLegend": "OK: dostupné · WARN: nenastaveno · ERROR: požadavek selhal nebo vypršel časový limit.",
"streamUrl": "Adresa URL streamu",
"unreachable": "nedosažitelný",
"refresh": "Obnovit"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Neexistuje žádná sada radio_stream_url, takže zatím není co vkládat.",
"goToSettings": "Přejděte do Nastavení rádia",
"livePreview": "Živý náhled",
"livePreviewText": "Toto je holý <audio> přehrávač, jak jej uvidí návštěvníci. Stisknutím tlačítka Přehrát potvrďte, že je stream dosažitelný.",
"livePreviewText": "Toto je holý audio přehrávač, jak jej uvidí návštěvníci. Stisknutím tlačítka Přehrát potvrďte, že je stream dosažitelný.",
"source": "Zdroj",
"audioSnippet": "Úryvek audio přehrávače",
"audioSnippetDesc": "Zvukový přehrávač HTML5 namířil přímo na stream. Funguje všude, kde je povoleno prosté HTML.",
@@ -1998,7 +1998,7 @@
"title": "Upozornění",
"subtitle": "Odesílejte upozornění pro celý hotel a prohlížejte si nedávná upozornění",
"sendAlert": "Odeslat upozornění na hotel",
"recentAlerts": "Nedávná upozornění",
"recentAlerts": "Nedávná upozornění ({count})",
"noRecentAlerts": "Žádná nedávná upozornění",
"form": {
"title": "Název upozornění",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importovat Oprávnění",
"importHint": "Paste a JSON array of Povolení objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Povolení objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importovat",
"exportTitle": "Export Oprávnění",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Předvolby motivu",
"presetsSubtitle": "Vyberte předem připravené barevné schéma",
"applyPreset": "Použít",
"presetApplied": "Předvolba použita",
"presetApplied": "Předvolba použita: {preset}",
"customiseTitle": "Vlastní barvy",
"primary": "Primární",
"secondary": "Sekundární",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "intet API-sæt, der nu spiller",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Slutpunktsdiagnostik",
"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> tilgængelig · <span class=\"inline-block text-[0.72rem] font-bold px-2 rounded-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ADVARSEL</span> ikke konfigureret · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text) / 22 text-[var(--farve mislykket] OR.",
"diagnosticsLegend": "OK: tilgængelig · WARN: ikke konfigureret · ERROR: anmodningen mislykkedes eller fik timeout.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Opdater"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Der er ingen radio_stream_url sat, så der er intet at indlejre endnu.",
"goToSettings": "Gå til Radioindstillinger",
"livePreview": "Live preview",
"livePreviewText": "Dette er den blottede <audio>-afspiller, som besøgende vil se den. Tryk på afspil for at bekræfte, at streamen er tilgængelig.",
"livePreviewText": "Dette er den blottede audio-afspiller, som besøgende vil se den. Tryk på afspil for at bekræfte, at streamen er tilgængelig.",
"source": "Kilde",
"audioSnippet": "Uddrag af lydafspiller",
"audioSnippetDesc": "Drop-in HTML5-lydafspiller pegede direkte mod strømmen. Virker overalt, hvor almindelig HTML er tilladt.",
@@ -1998,7 +1998,7 @@
"title": "Advarsler",
"subtitle": "Send underretninger for hele hotellet, og se de seneste underretninger",
"sendAlert": "Send hotelalarm",
"recentAlerts": "Seneste underretninger",
"recentAlerts": "Seneste underretninger ({count})",
"noRecentAlerts": "No recent alerts",
"form": {
"title": "Alert title",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer Tilladelser",
"importHint": "Paste a JSON array of Tilladelse objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tilladelse objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export Tilladelser",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Forudindstillinger af tema",
"presetsSubtitle": "Vælg et forudbygget farveskema",
"applyPreset": "Ansøg",
"presetApplied": "Forudindstilling anvendt",
"presetApplied": "Forudindstilling anvendt: {preset}",
"customiseTitle": "Brugerdefinerede farver",
"primary": "Primær",
"secondary": "Sekundær",
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: erreichbar · WARN: nicht konfiguriert · ERROR: Anfrage fehlgeschlagen oder Zeitüberschreitung.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Aktualisieren"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alarme",
"subtitle": "Hotelweite Alarme senden und aktuelle anzeigen",
"sendAlert": "Hotel-Alarm senden",
"recentAlerts": "Letzte Alarme",
"recentAlerts": "Letzte Alarme ({count})",
"noRecentAlerts": "Keine aktuellen Alarme",
"form": {
"title": "Alarm-Titel",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importieren Berechtigungen",
"importHint": "Paste a JSON array of Berechtigung objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Berechtigung objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importieren",
"exportTitle": "Export Berechtigungen",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Design-Voreinstellungen",
"presetsSubtitle": "Wähle ein vorgefertigtes Farbschema",
"applyPreset": "Anwenden",
"presetApplied": "Voreinstellung angewendet",
"presetApplied": "Voreinstellung angewendet: {preset}",
"customiseTitle": "Benutzerdefinierte Farben",
"primary": "Primär",
"secondary": "Sekundär",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "δεν υπάρχει σύνολο API που παίζει τώρα",
"noListenersHint": "δεν έχει οριστεί API ακροατών",
"endpointDiagnostics": "Διαγνωστικά τελικού σημείου",
"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)]\">ΟΚ</span> προσβάσιμο · <span class=\"inline-block text-[0.72-rounded-5-0.72remful. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ΠΡΟΕΙΔΟΠΟΙΗΣΗ</span> δεν έχει διαμορφωθεί · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/2 text-[var(--color-text)]\">ΣΦΑΛΜΑ</span> η ανάκτηση απέτυχε / έληξε.",
"diagnosticsLegend": "OK: διαθέσιμο · WARN: δεν έχει ρυθμιστεί · ERROR: αποτυχία αιτήματος ή λήξη χρονικού ορίου.",
"streamUrl": "URL ροής",
"unreachable": "απρόσιτος",
"refresh": "Ανανέωση"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Δεν υπάρχει set radio_stream_url, επομένως δεν υπάρχει τίποτα για ενσωμάτωση ακόμα.",
"goToSettings": "Μεταβείτε στις Ρυθμίσεις ραδιοφώνου",
"livePreview": "Ζωντανή προεπισκόπηση",
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής <audio> όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής audio όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
"source": "Πηγή",
"audioSnippet": "Απόσπασμα αναπαραγωγής ήχου",
"audioSnippetDesc": "Το αναπτυσσόμενο πρόγραμμα αναπαραγωγής ήχου HTML5 κατευθείαν στη ροή. Λειτουργεί οπουδήποτε επιτρέπεται απλό HTML.",
@@ -1998,7 +1998,7 @@
"title": "Ειδοποιήσεις",
"subtitle": "Στείλτε ειδοποιήσεις σε όλο το ξενοδοχείο και δείτε πρόσφατες ειδοποιήσεις",
"sendAlert": "Αποστολή ειδοποίησης ξενοδοχείου",
"recentAlerts": "Πρόσφατες ειδοποιήσεις",
"recentAlerts": "Πρόσφατες ειδοποιήσεις ({count})",
"noRecentAlerts": "Δεν υπάρχουν πρόσφατες ειδοποιήσεις",
"form": {
"title": "Τίτλος ειδοποίησης",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Εισαγωγή Άδειες",
"importHint": "Paste a JSON array of Άδεια objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Άδεια objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Εισαγωγή",
"exportTitle": "Export Άδειες",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Προεπιλογές θεμάτων",
"presetsSubtitle": "Επιλέξτε έναν προκατασκευασμένο συνδυασμό χρωμάτων",
"applyPreset": "Εφαρμόστε",
"presetApplied": "Εφαρμόστηκε η προεπιλογή",
"presetApplied": "Εφαρμόστηκε η προεπιλογή: {preset}",
"customiseTitle": "Προσαρμοσμένα χρώματα",
"primary": "Πρωτοβάθμια",
"secondary": "Δευτερεύουσα",
+268 -32
View File
@@ -878,7 +878,8 @@
"codePlaceholder": "6-digit code",
"confirm": "Confirm",
"enableIntro": "Add a second layer of security with an authenticator app.",
"enable": "Enable 2FA"
"enable": "Enable 2FA",
"rateLimit": "Too many attempts. Please wait before trying again."
},
"login": {
"title": "Sign in",
@@ -1119,7 +1120,8 @@
"rankWithCount": "Rank {rank}: {count} accounts",
"recentStaffActivity": "Recent staff activity",
"viewAll": "View all",
"noStaffActivityLogged": "No staff activity logged yet"
"noStaffActivityLogged": "No staff activity logged yet",
"totalAccounts": "{count} total accounts"
},
"articles": {
"title": "Articles",
@@ -1160,7 +1162,11 @@
"statusPublished": "Published",
"publishAt": "Publish at",
"publishedAt": "Published at"
}
},
"articlesShown": "Articles shown",
"latest": "Latest article",
"mostRecentPost": "Most recent publication",
"recentArticles": "Recent articles"
},
"events": {
"newEvent": "+ New event",
@@ -1281,7 +1287,13 @@
"colColour": "Colour",
"colUsedBy": "Used by",
"colCreated": "Created",
"colActions": "Actions"
"colActions": "Actions",
"databaseError": "Tags could not be loaded. Please try again.",
"totalTags": "Total tags",
"inUse": "In use",
"attachedToContent": "Attached to content",
"unused": "Unused",
"allTags": "All tags"
},
"navigation": {
"title": "Navigator",
@@ -1305,7 +1317,12 @@
"colName": "Name",
"colOrder": "Order",
"colRooms": "Rooms",
"colActions": "Actions"
"colActions": "Actions",
"publicFlatcats": "Public room categories",
"markedPublic": "Categories marked as public",
"visibleRooms": "Visible rooms",
"rows": "rows",
"unnamed": "Unnamed room"
},
"helpCenter": {
"title": "Help Center",
@@ -1332,7 +1349,9 @@
"saving": "Saving…",
"deleteEntry": "Delete entry",
"cancel": "Cancel"
}
},
"withButton": "With a button",
"smallBoxes": "Small boxes"
},
"ads": {
"title": "Advertisements",
@@ -1394,8 +1413,17 @@
"save": "Save box",
"saving": "Saving…",
"deleteBox": "Delete box",
"cancel": "Cancel"
}
"cancel": "Cancel",
"iconEmoji": "Icon or emoji"
},
"totalBoxes": "Total boxes",
"active": "Active",
"shownOnHomePage": "Shown on the home page",
"position": "Position",
"boxes": "Content boxes",
"hidden": "Hidden",
"hide": "Hide",
"activate": "Activate"
},
"photos": {
"title": "Photo Moderation",
@@ -1522,7 +1550,20 @@
"save": "Save ban",
"saving": "Saving…",
"cancel": "Cancel"
}
},
"activeBans": "Active bans",
"permanent": "Permanent bans",
"accountBans": "Account bans",
"userIdPlaceholder": "User ID",
"typeAccount": "Account",
"typeIp": "IP address",
"typeMachine": "Device",
"typeSuper": "Account, IP and device",
"hoursPlaceholder": "Duration in hours",
"reasonPlaceholder": "Reason for the ban",
"ban": "Ban user",
"colUserId": "User ID",
"lift": "Lift ban"
},
"ip": {
"title": "IP Management",
@@ -1542,7 +1583,11 @@
"colIp": "IP",
"colReason": "Reason",
"colAdded": "Added",
"colActions": "Actions"
"colActions": "Actions",
"whitelisted": "Allowed IP addresses",
"blacklisted": "Blocked IP addresses",
"asnPlaceholder": "ASN (optional)",
"colAsn": "ASN"
},
"vpn": {
"title": "VPN / Proxy Detection",
@@ -1555,7 +1600,21 @@
"blockOnDetectHint": "Automatically block users detected behind a VPN or proxy",
"save": "Save settings",
"saving": "Saving…",
"settingsSaved": "VPN settings saved successfully"
"settingsSaved": "VPN settings saved successfully",
"protection": "VPN protection",
"on": "Enabled",
"off": "Disabled",
"providerLabel": "Provider: {provider}",
"notScreened": "Connections are not checked for VPN use.",
"settings": "VPN settings",
"provider": "Provider",
"providerHint": "Choose the service used to check incoming connections.",
"blockMessage": "Blocked connection message",
"blockMessagePlaceholder": "Please disable your VPN and try again.",
"blockMessageHint": "Shown when a connection is blocked by VPN protection.",
"rawIpTitle": "IP address lists",
"rawIpDescription": "Manage individual addresses in <ipLink>IP management</ipLink>.",
"openIpManagement": "Open IP management"
},
"tickets": {
"title": "CMS support desk",
@@ -1685,7 +1744,10 @@
"status": "Status",
"submitted": "Submitted",
"noMessage": "No message provided"
}
},
"applicationsTitle": "Applications ({count})",
"userNumber": "User #{id}",
"rankLabel": "Rank {rank}"
},
"teams": {
"title": "Teams",
@@ -1713,7 +1775,26 @@
"save": "Save team definition",
"saving": "Saving…",
"cancel": "Cancel"
}
},
"teamRanks": "Staff ranks",
"visible": "Visible",
"hidden": "Hidden",
"newTeamRank": "New staff rank",
"rankName": "Rank name",
"rankNamePlaceholder": "For example: Moderator",
"badgeCode": "Badge code",
"badgeCodePlaceholder": "For example: ADM",
"jobDescription": "Job description",
"jobDescriptionPlaceholder": "Describe this staff role",
"staffColor": "Staff colour",
"staffColorPlaceholder": "For example: #3366cc",
"hiddenRank": "Hide from the staff page",
"createTeam": "Create staff rank",
"ranksTitle": "Staff ranks ({count})",
"badge": "Badge",
"color": "Colour",
"visibility": "Visibility",
"delete": "Delete"
},
"permissions": {
"title": "Permissions",
@@ -1888,8 +1969,23 @@
"enabled": "Enabled",
"save": "Save package",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"infoPlaceholder": "Describe the package contents",
"iconPlaceholder": "Icon URL",
"colorPlaceholder": "For example: #3366cc",
"costsPlaceholder": "Price",
"creditsPlaceholder": "Number of credits",
"ducketsPlaceholder": "Number of duckets",
"diamondsPlaceholder": "Number of diamonds",
"giveRankPlaceholder": "Rank ID (0 for none)",
"badgesPlaceholder": "Badge codes separated by semicolons",
"positionPlaceholder": "Display order",
"create": "Create package",
"deletePackage": "Delete package"
},
"newPackageTitle": "New shop package",
"backToShop": "Back to shop",
"editPackageTitle": "Edit shop package"
},
"marketplace": {
"title": "Marketplace",
@@ -2056,7 +2152,15 @@
"colActions": "Actions",
"statusActive": "Active",
"statusCancelled": "Cancelled",
"statusExpired": "Expired"
"statusExpired": "Expired",
"activeSubs": "Active subscriptions",
"types": "Subscription types",
"expiringSoon": "Expiring soon",
"activeSubscriptions": "Active subscriptions",
"colSubId": "Subscription ID",
"colUserId": "User ID",
"colType": "Type",
"colExpires": "Expires"
},
"rareValues": {
"title": "Rare Values",
@@ -2095,8 +2199,24 @@
"pointTypeDiamonds": "Diamonds",
"save": "Save item",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"itemIdPlaceholder": "Item ID",
"iconPlaceholder": "Icon URL",
"creditsPlaceholder": "Credits",
"currencyValuePlaceholder": "Currency value",
"addValue": "Add value"
},
"badgePlaceholder": "Category badge code",
"priorityPlaceholder": "Priority",
"addCategoryButton": "Add category",
"badgeLabel": "Badge: {badge}",
"priorityLabel": "Priority: {priority}",
"itemCount": "{count} items",
"colItemId": "Item ID",
"colCurrencyValue": "Currency value",
"colCurrency": "Currency",
"colIcon": "Icon",
"noValuesInCategory": "No values in this category yet."
},
"badges": {
"title": "Badges",
@@ -2127,7 +2247,10 @@
"colImage": "Image",
"colName": "Name",
"colDescription": "Description",
"colActions": "Actions"
"colActions": "Actions",
"uploadedMessage": "Badge {code} uploaded.",
"errorMessage": "Upload failed: {error}",
"userIdPlaceholder": "User ID"
},
"achievements": {
"title": "Achievements",
@@ -2141,7 +2264,14 @@
"colProgress": "Progress",
"levelLabel": "Level {level}",
"rewardCredits": "{count} credits",
"rewardPoints": "{count} {points}"
"rewardPoints": "{count} {points}",
"achievementRows": "Achievement levels",
"distinctAchievements": "Distinct achievements",
"categories": "Categories",
"colLevel": "Level",
"colRewardType": "Reward type",
"colRewardAmount": "Reward amount",
"colPoints": "Points"
},
"calendar": {
"title": "Calendar campaigns",
@@ -2160,8 +2290,32 @@
"days": "Days",
"dayLabel": "Day {day}",
"reward": "Reward",
"noDays": "No days configured for this campaign"
}
"noDays": "No days configured for this campaign",
"campaignMeta": "#{id} · {days} days · starts {date}",
"disabled": "Disabled",
"enabled": "Enabled",
"locksExpired": "Expired days are locked",
"rewards": "Rewards",
"noRewards": "No rewards configured.",
"colId": "ID",
"colProduct": "Product",
"colItem": "Item",
"colCredits": "Credits",
"colPixels": "Duckets",
"colPoints": "Points",
"colBadge": "Badge",
"colSubscription": "Subscription",
"typeLabel": "Type {type}",
"rewardCount": "{count} rewards"
},
"campaigns": "Campaigns",
"enabled": "Enabled campaigns",
"totalRewards": "Total rewards",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"campaignMeta": "#{id} · {days} days · {rewards} rewards",
"startsAt": "Starts: {date}",
"locksExpired": "Expired days are locked"
},
"radio": {
"title": "Radio Moderation",
@@ -2231,7 +2385,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2355,7 +2509,8 @@
"pointsPerMinuteHint": "Amount awarded for each full minute of listening.",
"maxPerDayHint": "Daily cap per user. Use 0 for unlimited.",
"minListenersHint": "Points are only awarded when at least this many people are tuned in.",
"save": "Save points settings"
"save": "Save points settings",
"saved": "Settings saved."
},
"autoDjForm": {
"title": "Radio AutoDJ",
@@ -2408,7 +2563,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: reachable · WARN: not configured · ERROR: request failed or timed out.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Refresh"
@@ -2589,7 +2744,16 @@
"valuePlaceholder": "Enter value…",
"save": "Save changes",
"cancel": "Cancel"
}
},
"settingsKeys": "Settings",
"textKeys": "Text entries",
"keysCount": "{count} keys",
"addOverwriteSetting": "Add or update a setting",
"key": "Key",
"value": "Value",
"showingFirst": "Showing the first {count}",
"addOverwriteText": "Add or update a text",
"truncatedHint": "Showing {shown} of {total} entries."
},
"alerts": {
"title": "Alerts",
@@ -2857,7 +3021,25 @@
"save": "Save settings",
"saving": "Saving…",
"confirmEnable": "Enabling maintenance will prevent all non-staff users from accessing the hotel. Continue?",
"confirmDisable": "Disable maintenance mode and restore access for all users?"
"confirmDisable": "Disable maintenance mode and restore access for all users?",
"maintenance": "Maintenance",
"hotelLockedDown": "Access to the hotel is restricted.",
"hotelOpen": "The hotel is open.",
"minLoginRank": "Minimum login rank",
"duringMaintenance": "During maintenance",
"splashMessage": "Maintenance message",
"set": "Configured",
"empty": "Empty",
"currentState": "Current status",
"maintenanceCurrently": "Maintenance: {status}",
"onlyRankCanLogin": "Only users with rank {rank} or higher can log in.",
"hotelOpenToEveryone": "All eligible users can log in.",
"settings": "Settings",
"enableMaintenance": "Enable maintenance mode",
"minLoginRankLabel": "Minimum rank allowed during maintenance",
"maintenanceMessageLabel": "Message shown to visitors",
"maintenanceMessagePlaceholder": "Explain why the hotel is unavailable.",
"saveChanges": "Save changes"
},
"housekeeping": {
"title": "Permission archive",
@@ -2941,7 +3123,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Import permissions",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects with the fields permission, minRank, description, groupName and dependsOn.",
"importPlaceholder": "Paste JSON here…",
"import": "Import",
"exportTitle": "Export permissions",
@@ -3058,8 +3240,13 @@
"variablesHint": "{username}, {email}, {hotel_name}",
"save": "Save template",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"variablesPlaceholder": "Variables, separated by commas",
"active": "Active"
},
"create": "Create template",
"save": "Save template",
"updated": "Updated: {date}"
},
"theme": {
"title": "Theme",
@@ -3085,7 +3272,12 @@
"saveCss": "Save CSS",
"savingCss": "Saving…",
"reset": "Reset to defaults",
"confirmReset": "Reset theme to default values?"
"confirmReset": "Reset theme to default values?",
"saved": "Theme settings saved.",
"presetApplied": "Preset applied: {preset}",
"typographyLayout": "Typography and layout",
"font": "Font",
"borderRadius": "Corner radius"
},
"media": {
"title": "Media Library",
@@ -3170,6 +3362,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "CMS translations",
"description": "Edit the text shown on the website and in housekeeping. Saved changes are applied on the next page load and survive deployments.",
"export": "Export JSON",
"saving": "Saving…",
"saveLocale": "Save {locale}",
"saved": "Translations saved and applied.",
"readOnly": "You can review and export translations. Editing requires permission to change settings.",
"language": "Language",
"coverage": "{total} keys · {missing} missing · {identical} identical to English",
"search": "Search",
"searchPlaceholder": "Key, translation or English source…",
"group": "Group",
"allGroups": "All groups",
"status": "Show",
"all": "All texts",
"missing": "Missing translations",
"identical": "Identical to English",
"modified": "Unsaved changes",
"fallbackHint": "Missing translations use English. Identical text may be intentional. Keep variable names inside braces unchanged.",
"unsaved": "{count} unsaved changes in this language",
"discard": "Discard changes",
"discardConfirm": "Discard unsaved changes in this language?",
"fixErrors": "Fix {count} invalid messages before saving.",
"noResults": "No texts match these filters.",
"source": "English reference",
"missingPlaceholder": "Translation missing; English is used.",
"page": "Page {page} of {total} · {count} keys",
"previous": "Previous",
"next": "Next",
"leaveWarning": "You have unsaved translations. Leave this page?",
"conflict": "These translations changed since you opened the editor. Export your changes, then reload before saving.",
"unknownKey": "Unknown translation key: {key}",
"empty": "Enter a translation for {key}, or discard this change.",
"invalid": "Invalid message syntax in {key}. Check braces and rich-text tags.",
"variables": "Keep the same variable names and rich-text tags as the English source in {key}."
}
},
"settings": {
@@ -3299,7 +3528,14 @@
"pages": "Pages and tools",
"description": "Find an admin page or search hotel records.",
"loadError": "Records could not be loaded. You can still open the pages above.",
"placeholder": "Search pages, users, rooms…"
"placeholder": "Search pages, users, rooms…",
"users": "Users",
"articles": "Articles",
"rooms": "Rooms",
"guilds": "Groups",
"shop": "Shop",
"rareValues": "Rare values",
"noResults": "No results found."
},
"table": {
"search": "Search…",
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: accesible · WARN: sin configurar · ERROR: solicitud fallida o tiempo agotado.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Actualizar"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alertas",
"subtitle": "Enviar alertas a todo el hotel y ver alertas recientes",
"sendAlert": "Enviar alerta al hotel",
"recentAlerts": "Alertas recientes",
"recentAlerts": "Alertas recientes ({count})",
"noRecentAlerts": "No hay alertas recientes",
"form": {
"title": "Título de la alerta",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importar Permisos",
"importHint": "Paste a JSON array of Permiso objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permiso objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importar",
"exportTitle": "Export Permisos",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Preajustes de tema",
"presetsSubtitle": "Elige un esquema de color predefinido",
"applyPreset": "Aplicar",
"presetApplied": "Preajuste aplicado",
"presetApplied": "Preajuste aplicado: {preset}",
"customiseTitle": "Colores personalizados",
"primary": "Primario",
"secondary": "Secundario",
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK : accessible · WARN : non configuré · ERROR : échec de la requête ou délai dépassé.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Actualiser"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alertes",
"subtitle": "Envoyer des alertes à tout l'hôtel et voir les alertes récentes",
"sendAlert": "Envoyer une alerte à l'hôtel",
"recentAlerts": "Alertes récentes",
"recentAlerts": "Alertes récentes ({count})",
"noRecentAlerts": "Aucune alerte récente",
"form": {
"title": "Titre de l'alerte",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer permissions",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export permissions",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Préréglages de thème",
"presetsSubtitle": "Choisir un jeu de couleurs prédéfini",
"applyPreset": "Appliquer",
"presetApplied": "Préréglage appliqué",
"presetApplied": "Préréglage appliqué: {preset}",
"customiseTitle": "Couleurs personnalisées",
"primary": "Primaire",
"secondary": "Secondaire",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nema postavljenog API-ja koji se sada igra",
"noListenersHint": "nije postavljen API slušatelja",
"endpointDiagnostics": "Dijagnostika krajnje točke",
"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> dostupno · <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> nije konfigurirano · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">GREŠKA</span> dohvaćanje nije uspjelo / isteklo je vrijeme.",
"diagnosticsLegend": "OK: dostupno · WARN: nije konfigurirano · ERROR: zahtjev nije uspio ili je istekao.",
"streamUrl": "URL streama",
"unreachable": "nedostižan",
"refresh": "Osvježi"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nije postavljen radio_stream_url, tako da još nema ništa za ugraditi.",
"goToSettings": "Idite na Radio Settings",
"livePreview": "Pregled uživo",
"livePreviewText": "Ovo je goli <audio> player kako će ga posjetitelji vidjeti. Pritisnite play kako biste potvrdili da je stream dostupan.",
"livePreviewText": "Ovo je goli audio player kako će ga posjetitelji vidjeti. Pritisnite play kako biste potvrdili da je stream dostupan.",
"source": "Izvor",
"audioSnippet": "Isječak audio playera",
"audioSnippetDesc": "Umetnuti HTML5 audio player usmjeren ravno prema streamu. Radi svugdje gdje je dopušten obični HTML.",
@@ -1998,7 +1998,7 @@
"title": "upozorenja",
"subtitle": "Šaljite upozorenja cijelom hotelu i pogledajte nedavna upozorenja",
"sendAlert": "Pošalji hotelsko upozorenje",
"recentAlerts": "Nedavna upozorenja",
"recentAlerts": "Nedavna upozorenja ({count})",
"noRecentAlerts": "Nema nedavnih upozorenja",
"form": {
"title": "Naslov upozorenja",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Uvoz Dozvole",
"importHint": "Paste a JSON array of Dozvola objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Dozvola objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "uvoz",
"exportTitle": "Export Dozvole",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Unaprijed postavljene teme",
"presetsSubtitle": "Odaberite unaprijed pripremljenu shemu boja",
"applyPreset": "primijeniti",
"presetApplied": "Unaprijed postavljeno",
"presetApplied": "Unaprijed postavljeno: {preset}",
"customiseTitle": "Prilagođene boje",
"primary": "Primarni",
"secondary": "Sekundarna",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nincs jelenleg lejátszott API-készlet",
"noListenersHint": "nincs hallgató API beállítva",
"endpointDiagnostics": "Végpont diagnosztika",
"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> elérhető · <span class=\"inline-block text-[0.72rem] font-bold.5 round-0 px-2 edpy-0 px-2 ed bg-[var(--color-primary)]/18 text-[var(--color-text)]\">FIGYELMEZTETÉS</span> nincs konfigurálva · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(]--ERtch failed-R)\">fe.",
"diagnosticsLegend": "OK: elérhető · WARN: nincs beállítva · ERROR: sikertelen kérés vagy időtúllépés.",
"streamUrl": "Stream URL",
"unreachable": "elérhetetlen",
"refresh": "Frissítés"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nincs beállítva radio_stream_url, így még nincs mit beágyazni.",
"goToSettings": "Menjen a Rádióbeállításokhoz",
"livePreview": "Élő előnézet",
"livePreviewText": "Ez a csupasz <audio> lejátszó, ahogy a látogatók látni fogják. Nyomja meg a lejátszás gombot, hogy megerősítse, hogy az adatfolyam elérhető.",
"livePreviewText": "Ez a csupasz audio lejátszó, ahogy a látogatók látni fogják. Nyomja meg a lejátszás gombot, hogy megerősítse, hogy az adatfolyam elérhető.",
"source": "Forrás",
"audioSnippet": "Hanglejátszó részlet",
"audioSnippetDesc": "A beugró HTML5 audiolejátszó egyenesen a streamre mutatott. Mindenhol működik, ahol a sima HTML megengedett.",
@@ -1998,7 +1998,7 @@
"title": "Figyelmeztetések",
"subtitle": "A szálloda egészére kiterjedő figyelmeztetések küldése és a legutóbbi figyelmeztetések megtekintése",
"sendAlert": "Szállodai figyelmeztetés küldése",
"recentAlerts": "Legutóbbi figyelmeztetések",
"recentAlerts": "Legutóbbi figyelmeztetések ({count})",
"noRecentAlerts": "Nincsenek friss figyelmeztetések",
"form": {
"title": "Figyelmeztetés címe",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importálás Engedélyek",
"importHint": "Paste a JSON array of Engedély objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Engedély objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importálás",
"exportTitle": "Export Engedélyek",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Előre beállított téma",
"presetsSubtitle": "Válasszon egy előre elkészített színsémát",
"applyPreset": "Alkalmazni",
"presetApplied": "Előbeállítás alkalmazva",
"presetApplied": "Előbeállítás alkalmazva: {preset}",
"customiseTitle": "Egyedi színek",
"primary": "Elsődleges",
"secondary": "Másodlagos",
+306 -39
View File
@@ -848,7 +848,8 @@
"codePlaceholder": "Codice a 6 cifre",
"confirm": "Conferma",
"enableIntro": "Aggiungi un livello di sicurezza in più con un'app di autenticazione.",
"enable": "Attiva 2FA"
"enable": "Attiva 2FA",
"rateLimit": "Troppi tentativi. Attendi prima di riprovare."
},
"title": "Account collegati",
"subtitle": "Collega un account social per poter accedere con un clic",
@@ -906,7 +907,12 @@
"alreadyHaveAccountPre": "Hai già un account?",
"alreadyHaveAccountLink": "Accedi",
"whoIsOnline": "Chi è online",
"usersOnline": "{count} online"
"usersOnline": "{count} online",
"accepted": "Accettato e confermato",
"showPassword": "Mostra",
"hidePassword": "Nascondi",
"accountDetails": "Dettagli dell’account",
"credentials": "Credenziali e sicurezza"
},
"forgot": {
"title": "Reimposta password",
@@ -1061,7 +1067,17 @@
"themeBuilder": "Editor del tema",
"catalogMaintenance": "Manutenzione catalogo",
"openSidebar": "Apri menu",
"closeSidebar": "Chiudi menu"
"closeSidebar": "Chiudi menu",
"search": {
"placeholder": "Cerca…",
"noResults": "Nessun risultato trovato",
"users": "Utenti",
"articles": "Articoli",
"rooms": "Stanze",
"guilds": "Gruppi",
"shop": "Negozio",
"hint": "Inizia a scrivere per cercare tra utenti, articoli, stanze, gruppi e articoli del negozio"
}
},
"dashboard": {
"title": "Dashboard",
@@ -1078,7 +1094,8 @@
"rankWithCount": "Grado {rank}: {count} account",
"recentStaffActivity": "Attività recente dello staff",
"viewAll": "Vedi tutto",
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata"
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata",
"totalAccounts": "{count} account totali"
},
"articles": {
"title": "Articoli",
@@ -1119,7 +1136,11 @@
"statusPublished": "Pubblicato",
"publishAt": "Data di pubblicazione",
"publishedAt": "Pubblicato il"
}
},
"articlesShown": "Articoli mostrati",
"latest": "Ultimo articolo",
"mostRecentPost": "Pubblicazione più recente",
"recentArticles": "Articoli recenti"
},
"events": {
"newEvent": "+ Nuovo evento",
@@ -1240,7 +1261,13 @@
"colColour": "Colore",
"colUsedBy": "Usato da",
"colCreated": "Creato",
"colActions": "Azioni"
"colActions": "Azioni",
"databaseError": "Impossibile caricare i tag. Riprova.",
"totalTags": "Tag totali",
"inUse": "In uso",
"attachedToContent": "Associati a contenuti",
"unused": "Non utilizzati",
"allTags": "Tutti i tag"
},
"navigation": {
"title": "Navigatore",
@@ -1264,7 +1291,12 @@
"colName": "Nome",
"colOrder": "Ordine",
"colRooms": "Stanze",
"colActions": "Azioni"
"colActions": "Azioni",
"publicFlatcats": "Categorie delle stanze pubbliche",
"markedPublic": "Categorie contrassegnate come pubbliche",
"visibleRooms": "Stanze visibili",
"rows": "righe",
"unnamed": "Stanza senza nome"
},
"helpCenter": {
"title": "Centro assistenza",
@@ -1291,7 +1323,9 @@
"saving": "Salvataggio…",
"deleteEntry": "Elimina voce",
"cancel": "Annulla"
}
},
"withButton": "Con pulsante",
"smallBoxes": "Riquadri piccoli"
},
"ads": {
"title": "Pubblicità",
@@ -1354,8 +1388,17 @@
"save": "Salva box",
"saving": "Salvataggio…",
"deleteBox": "Elimina box",
"cancel": "Annulla"
}
"cancel": "Annulla",
"iconEmoji": "Icona o emoji"
},
"totalBoxes": "Riquadri totali",
"active": "Attivo",
"shownOnHomePage": "Mostrato nella pagina iniziale",
"position": "Posizione",
"boxes": "Riquadri di contenuto",
"hidden": "Nascosto",
"hide": "Nascondi",
"activate": "Attiva"
},
"photos": {
"title": "Moderazione foto",
@@ -1482,7 +1525,20 @@
"save": "Salva ban",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"activeBans": "Ban attivi",
"permanent": "Ban permanenti",
"accountBans": "Ban degli account",
"userIdPlaceholder": "ID utente",
"typeAccount": "Account",
"typeIp": "Indirizzo IP",
"typeMachine": "Dispositivo",
"typeSuper": "Account, IP e dispositivo",
"hoursPlaceholder": "Durata in ore",
"reasonPlaceholder": "Motivo del ban",
"ban": "Banna utente",
"colUserId": "ID utente",
"lift": "Revoca ban"
},
"ip": {
"title": "Gestione IP",
@@ -1502,7 +1558,11 @@
"colIp": "IP",
"colReason": "Motivo",
"colAdded": "Aggiunto",
"colActions": "Azioni"
"colActions": "Azioni",
"whitelisted": "Indirizzi IP consentiti",
"blacklisted": "Indirizzi IP bloccati",
"asnPlaceholder": "ASN (facoltativo)",
"colAsn": "ASN"
},
"vpn": {
"title": "Rilevamento VPN / Proxy",
@@ -1515,7 +1575,21 @@
"blockOnDetectHint": "Blocca automaticamente gli utenti rilevati dietro VPN o proxy",
"save": "Salva impostazioni",
"saving": "Salvataggio…",
"settingsSaved": "Impostazioni VPN salvate con successo"
"settingsSaved": "Impostazioni VPN salvate con successo",
"protection": "Protezione VPN",
"on": "Attiva",
"off": "Disattivata",
"providerLabel": "Provider: {provider}",
"notScreened": "Le connessioni non vengono controllate per l’uso di VPN.",
"settings": "Impostazioni VPN",
"provider": "Provider",
"providerHint": "Scegli il servizio per controllare le connessioni in entrata.",
"blockMessage": "Messaggio per le connessioni bloccate",
"blockMessagePlaceholder": "Disattiva la VPN e riprova.",
"blockMessageHint": "Mostrato quando una connessione viene bloccata dalla protezione VPN.",
"rawIpTitle": "Elenchi degli indirizzi IP",
"rawIpDescription": "Gestisci i singoli indirizzi in <ipLink>Gestione IP</ipLink>.",
"openIpManagement": "Apri gestione IP"
},
"tickets": {
"title": "Supporto CMS",
@@ -1621,7 +1695,10 @@
"status": "Stato",
"submitted": "Inviata",
"noMessage": "Nessun messaggio fornito"
}
},
"applicationsTitle": "Candidature ({count})",
"userNumber": "Utente #{id}",
"rankLabel": "Grado {rank}"
},
"teams": {
"title": "Team",
@@ -1649,7 +1726,26 @@
"save": "Salva definizione team",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"teamRanks": "Gradi dello staff",
"visible": "Visibili",
"hidden": "Nascosti",
"newTeamRank": "Nuovo grado dello staff",
"rankName": "Nome del grado",
"rankNamePlaceholder": "Ad esempio: Moderatore",
"badgeCode": "Codice del distintivo",
"badgeCodePlaceholder": "Ad esempio: ADM",
"jobDescription": "Descrizione dell’incarico",
"jobDescriptionPlaceholder": "Descrivi questo incarico dello staff",
"staffColor": "Colore dello staff",
"staffColorPlaceholder": "Ad esempio: #3366cc",
"hiddenRank": "Nascondi dalla pagina staff",
"createTeam": "Crea grado dello staff",
"ranksTitle": "Gradi dello staff ({count})",
"badge": "Distintivo",
"color": "Colore",
"visibility": "Visibilità",
"delete": "Elimina"
},
"permissions": {
"title": "Permessi",
@@ -1792,8 +1888,23 @@
"enabled": "Attivo",
"save": "Salva pacchetto",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"infoPlaceholder": "Descrivi il contenuto del pacchetto",
"iconPlaceholder": "URL dell’icona",
"colorPlaceholder": "Ad esempio: #3366cc",
"costsPlaceholder": "Prezzo",
"creditsPlaceholder": "Numero di crediti",
"ducketsPlaceholder": "Numero di duckets",
"diamondsPlaceholder": "Numero di diamanti",
"giveRankPlaceholder": "ID grado (0 per nessuno)",
"badgesPlaceholder": "Codici dei distintivi separati da punto e virgola",
"positionPlaceholder": "Ordine di visualizzazione",
"create": "Crea pacchetto",
"deletePackage": "Elimina pacchetto"
},
"newPackageTitle": "Nuovo pacchetto del negozio",
"backToShop": "Torna al negozio",
"editPackageTitle": "Modifica pacchetto del negozio"
},
"marketplace": {
"title": "Marketplace",
@@ -1837,7 +1948,20 @@
"back": "Torna ai gruppi",
"viewPublic": "Apri pagina pubblica",
"disband": "Sciogli gruppo",
"listHeading": "Guilds ({count})"
"listHeading": "Guilds ({count})",
"edit": "Modifica gruppo",
"save": "Salva",
"saving": "Salvataggio…",
"updated": "Gruppo aggiornato",
"updateError": "Impossibile aggiornare il gruppo",
"formName": "Nome",
"formDescription": "Descrizione",
"formState": "Stato",
"formForum": "Forum",
"formReadForum": "Leggere il forum",
"formPostMessages": "Pubblicare messaggi",
"formPostThreads": "Creare discussioni",
"formModForum": "Moderare il forum"
},
"transactions": {
"title": "Transazioni",
@@ -1926,7 +2050,9 @@
"save": "Salva buono",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"updated": "Voucher aggiornato",
"updateError": "Impossibile aggiornare il voucher"
},
"subscriptions": {
"title": "Abbonamenti",
@@ -1945,7 +2071,15 @@
"colActions": "Azioni",
"statusActive": "Attivo",
"statusCancelled": "Annullato",
"statusExpired": "Scaduto"
"statusExpired": "Scaduto",
"activeSubs": "Abbonamenti attivi",
"types": "Tipi di abbonamento",
"expiringSoon": "In scadenza",
"activeSubscriptions": "Abbonamenti attivi",
"colSubId": "ID abbonamento",
"colUserId": "ID utente",
"colType": "Tipo",
"colExpires": "Scadenza"
},
"rareValues": {
"title": "Valori rari",
@@ -1983,8 +2117,25 @@
"pointTypeDiamonds": "Diamanti",
"save": "Salva articolo",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"itemIdPlaceholder": "ID furni",
"iconPlaceholder": "URL dell’icona",
"creditsPlaceholder": "Crediti",
"currencyValuePlaceholder": "Valore in valuta",
"addValue": "Aggiungi valore"
},
"badgePlaceholder": "Codice del distintivo della categoria",
"priorityPlaceholder": "Priorità",
"addCategoryButton": "Aggiungi categoria",
"badgeLabel": "Distintivo: {badge}",
"priorityLabel": "Priorità: {priority}",
"itemCount": "{count} furni",
"colItemId": "ID furni",
"colCurrencyValue": "Valore in valuta",
"colCurrency": "Valuta",
"colIcon": "Icona",
"noValuesInCategory": "Nessun valore ancora presente in questa categoria.",
"editValue": "Modifica valore"
},
"badges": {
"title": "Distintivi",
@@ -2015,7 +2166,10 @@
"colImage": "Immagine",
"colName": "Nome",
"colDescription": "Descrizione",
"colActions": "Azioni"
"colActions": "Azioni",
"uploadedMessage": "Distintivo {code} caricato.",
"errorMessage": "Caricamento non riuscito: {error}",
"userIdPlaceholder": "ID utente"
},
"achievements": {
"title": "Trofei",
@@ -2029,7 +2183,14 @@
"colProgress": "Progresso",
"levelLabel": "Livello {level}",
"rewardCredits": "{count} crediti",
"rewardPoints": "{count} {points}"
"rewardPoints": "{count} {points}",
"achievementRows": "Livelli dei traguardi",
"distinctAchievements": "Traguardi distinti",
"categories": "Categorie",
"colLevel": "Livello",
"colRewardType": "Tipo di ricompensa",
"colRewardAmount": "Quantità della ricompensa",
"colPoints": "Punti"
},
"calendar": {
"title": "Campagne calendario",
@@ -2048,8 +2209,32 @@
"days": "Giorni",
"dayLabel": "Giorno {day}",
"reward": "Ricompensa",
"noDays": "Nessun giorno configurato per questa campagna"
}
"noDays": "Nessun giorno configurato per questa campagna",
"campaignMeta": "#{id} · {days} giorni · inizio {date}",
"disabled": "Disattivata",
"enabled": "Attiva",
"locksExpired": "Giorni scaduti bloccati",
"rewards": "Ricompense",
"noRewards": "Nessuna ricompensa configurata.",
"colId": "ID",
"colProduct": "Prodotto",
"colItem": "Furni",
"colCredits": "Crediti",
"colPixels": "Duckets",
"colPoints": "Punti",
"colBadge": "Distintivo",
"colSubscription": "Abbonamento",
"typeLabel": "Tipo {type}",
"rewardCount": "{count} ricompense"
},
"campaigns": "Campagne",
"enabled": "Campagne attive",
"totalRewards": "Ricompense totali",
"statusEnabled": "Attiva",
"statusDisabled": "Disattivata",
"campaignMeta": "#{id} · {days} giorni · {rewards} ricompense",
"startsAt": "Inizio: {date}",
"locksExpired": "Giorni scaduti bloccati"
},
"radio": {
"title": "Moderazione radio",
@@ -2082,7 +2267,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: raggiungibile · WARN: non configurato · ERROR: richiesta non riuscita o scaduta.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Aggiorna"
@@ -2141,7 +2326,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "Questo è il lettore audio mostrato ai visitatori. Avvia la riproduzione per verificare che lo streaming sia raggiungibile.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2248,7 +2433,7 @@
"pointsForm": {
"title": "Configurazione punti ascoltatore",
"subtitle": "Reward listeners with currency for time spent on the radio. Edits write to website_settings (radio_points_* keys) and bust the runtime settings cache. Booleans use 0 / 1.",
"saved": "Saved",
"saved": "Impostazioni salvate.",
"points": "Points",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -2478,13 +2663,22 @@
"valuePlaceholder": "Inserisci valore…",
"save": "Salva modifiche",
"cancel": "Annulla"
}
},
"settingsKeys": "Impostazioni",
"textKeys": "Voci di testo",
"keysCount": "{count} chiavi",
"addOverwriteSetting": "Aggiungi o aggiorna un’impostazione",
"key": "Chiave",
"value": "Valore",
"showingFirst": "Prime {count} voci mostrate",
"addOverwriteText": "Aggiungi o aggiorna un testo",
"truncatedHint": "Mostrate {shown} voci su {total}."
},
"alerts": {
"title": "Avvisi",
"subtitle": "Invia avvisi a tutto l'hotel e visualizza avvisi recenti",
"sendAlert": "Invia avviso hotel",
"recentAlerts": "Avvisi recenti",
"recentAlerts": "Avvisi recenti ({count})",
"noRecentAlerts": "Nessun avviso recente",
"form": {
"title": "Titolo avviso",
@@ -2619,7 +2813,25 @@
"save": "Salva impostazioni",
"saving": "Salvataggio…",
"confirmEnable": "Attivare la manutenzione impedirà a tutti gli utenti non dello staff di accedere all'hotel. Continuare?",
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?"
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?",
"maintenance": "Manutenzione",
"hotelLockedDown": "Accesso all’hotel limitato.",
"hotelOpen": "L’hotel è aperto.",
"minLoginRank": "Grado minimo per accedere",
"duringMaintenance": "Durante la manutenzione",
"splashMessage": "Messaggio di manutenzione",
"set": "Configurato",
"empty": "Vuoto",
"currentState": "Stato attuale",
"maintenanceCurrently": "Manutenzione: {status}",
"onlyRankCanLogin": "Possono accedere solo gli utenti con grado {rank} o superiore.",
"hotelOpenToEveryone": "Tutti gli utenti abilitati possono accedere.",
"settings": "Impostazioni",
"enableMaintenance": "Attiva la modalità manutenzione",
"minLoginRankLabel": "Grado minimo consentito durante la manutenzione",
"maintenanceMessageLabel": "Messaggio mostrato ai visitatori",
"maintenanceMessagePlaceholder": "Spiega perché l’hotel non è disponibile.",
"saveChanges": "Salva modifiche"
},
"housekeeping": {
"title": "Archivio permessi",
@@ -2695,7 +2907,7 @@
"tabTest": "Test",
"tabAudit": "Log di audit",
"importPermissions": "Importa Permessi",
"importHint": "Paste a JSON array of Permesso objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Incolla un array JSON di permessi con i campi permission, minRank, description, groupName e dependsOn.",
"importPlaceholder": "Paste JSON here…",
"import": "Importa",
"exportTitle": "Export Permessi",
@@ -2820,8 +3032,13 @@
"variablesHint": "{username}, {email}, {hotel_name}",
"save": "Salva template",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"variablesPlaceholder": "Variabili separate da virgole",
"active": "Attivo"
},
"create": "Crea modello",
"save": "Salva modello",
"updated": "Aggiornato: {date}"
},
"theme": {
"title": "Tema",
@@ -2832,7 +3049,7 @@
"presetsTitle": "Preimpostazioni tema",
"presetsSubtitle": "Scegli una combinazione di colori predefinita",
"applyPreset": "Applica",
"presetApplied": "Preimpostazione applicata",
"presetApplied": "Preset applicato: {preset}",
"customiseTitle": "Colori personalizzati",
"primary": "Primario",
"secondary": "Secondario",
@@ -2848,7 +3065,11 @@
"saveCss": "Salva CSS",
"savingCss": "Salvataggio…",
"reset": "Ripristina valori predefiniti",
"confirmReset": "Ripristinare il tema ai valori predefiniti?"
"confirmReset": "Ripristinare il tema ai valori predefiniti?",
"saved": "Impostazioni del tema salvate.",
"typographyLayout": "Tipografia e impaginazione",
"font": "Carattere",
"borderRadius": "Raggio degli angoli"
},
"media": {
"title": "Libreria media",
@@ -2982,7 +3203,9 @@
"alerts": "Alert",
"toolsGroup": "Strumenti",
"ticketInbox": "Inbox",
"cmsErrors": "Errori CMS"
"cmsErrors": "Errori CMS",
"themeBuilder": "Editor del tema",
"studio": "Studio"
},
"events": "Eventi",
"eventsSubtitle": "Eventi hotel e tipi evento",
@@ -3105,6 +3328,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "Traduzioni del CMS",
"description": "Modifica i testi del sito e dell’housekeeping. Le modifiche salvate vengono applicate al caricamento successivo della pagina e restano dopo i deploy.",
"export": "Esporta JSON",
"saving": "Salvataggio…",
"saveLocale": "Salva {locale}",
"saved": "Traduzioni salvate e applicate.",
"readOnly": "Puoi consultare ed esportare le traduzioni. Per modificarle serve il permesso di modifica delle impostazioni.",
"language": "Lingua",
"coverage": "{total} chiavi · {missing} mancanti · {identical} identiche all’inglese",
"search": "Cerca",
"searchPlaceholder": "Chiave, traduzione o testo inglese…",
"group": "Gruppo",
"allGroups": "Tutti i gruppi",
"status": "Mostra",
"all": "Tutti i testi",
"missing": "Traduzioni mancanti",
"identical": "Identiche all’inglese",
"modified": "Modifiche non salvate",
"fallbackHint": "Le traduzioni mancanti usano l’inglese. Un testo identico può essere intenzionale. Mantieni invariati i nomi delle variabili tra parentesi graffe.",
"unsaved": "{count} modifiche non salvate in questa lingua",
"discard": "Annulla modifiche",
"discardConfirm": "Annullare le modifiche non salvate in questa lingua?",
"fixErrors": "Correggi {count} messaggi non validi prima di salvare.",
"noResults": "Nessun testo corrisponde ai filtri.",
"source": "Riferimento inglese",
"missingPlaceholder": "Traduzione mancante: viene usato l’inglese.",
"page": "Pagina {page} di {total} · {count} chiavi",
"previous": "Precedente",
"next": "Successiva",
"leaveWarning": "Ci sono traduzioni non salvate. Vuoi uscire dalla pagina?",
"conflict": "Queste traduzioni sono cambiate dopo l’apertura dell’editor. Esporta le modifiche e ricarica la pagina prima di salvare.",
"unknownKey": "Chiave di traduzione sconosciuta: {key}",
"empty": "Inserisci una traduzione per {key} oppure annulla questa modifica.",
"invalid": "Sintassi del messaggio non valida in {key}. Controlla le parentesi graffe e i tag del testo.",
"variables": "Mantieni gli stessi nomi delle variabili e tag del testo inglese in {key}."
}
},
"analytics": {
@@ -3243,7 +3503,14 @@
"pages": "Pagine e strumenti",
"description": "Trova una pagina di gestione o cerca nei dati dell’hotel.",
"loadError": "Impossibile caricare i risultati. Puoi comunque aprire le pagine elencate.",
"placeholder": "Cerca pagine, utenti, stanze…"
"placeholder": "Cerca pagine, utenti, stanze…",
"users": "Utenti",
"articles": "Articoli",
"rooms": "Stanze",
"guilds": "Gruppi",
"shop": "Negozio",
"rareValues": "Valori dei rari",
"noResults": "Nessun risultato trovato."
},
"table": {
"search": "Cerca…",
+41 -4
View File
@@ -2045,7 +2045,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2222,7 +2222,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: bereikbaar · WARN: niet ingesteld · ERROR: aanvraag mislukt of time-out.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Vernieuwen"
@@ -2297,7 +2297,7 @@
"title": "Meldingen",
"subtitle": "Hotelbrede meldingen verzenden en recente meldingen bekijken",
"sendAlert": "Hotelmelding verzenden",
"recentAlerts": "Recente meldingen",
"recentAlerts": "Recente meldingen ({count})",
"noRecentAlerts": "Geen recente meldingen",
"form": {
"title": "Melding titel",
@@ -2516,7 +2516,7 @@
"tabTest": "Test",
"tabAudit": "Auditlog",
"importPermissions": "Permissies importeren",
"importHint": "Plak een JSON-array met permissie-objecten. Formaat: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Plak een JSON-array met permissie-objecten. Formaat: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Plak JSON hier…",
"import": "Importeren",
"exportTitle": "Permissies exporteren",
@@ -2774,6 +2774,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "CMS-vertalingen",
"description": "Bewerk de teksten op de website en in housekeeping. Opgeslagen wijzigingen worden bij de volgende paginalading toegepast en blijven behouden na deployments.",
"export": "JSON exporteren",
"saving": "Opslaan…",
"saveLocale": "{locale} opslaan",
"saved": "Vertalingen opgeslagen en toegepast.",
"readOnly": "Je kunt vertalingen bekijken en exporteren. Bewerken vereist toestemming om instellingen te wijzigen.",
"language": "Taal",
"coverage": "{total} sleutels · {missing} ontbreken · {identical} gelijk aan Engels",
"search": "Zoeken",
"searchPlaceholder": "Sleutel, vertaling of Engelse brontekst…",
"group": "Groep",
"allGroups": "Alle groepen",
"status": "Tonen",
"all": "Alle teksten",
"missing": "Ontbrekende vertalingen",
"identical": "Gelijk aan Engels",
"modified": "Niet-opgeslagen wijzigingen",
"fallbackHint": "Ontbrekende vertalingen gebruiken Engels. Identieke tekst kan bewust zijn gekozen. Laat de namen van variabelen tussen accolades ongewijzigd.",
"unsaved": "{count} niet-opgeslagen wijzigingen in deze taal",
"discard": "Wijzigingen verwerpen",
"discardConfirm": "Niet-opgeslagen wijzigingen in deze taal verwerpen?",
"fixErrors": "Corrigeer {count} ongeldige teksten voordat je opslaat.",
"noResults": "Geen teksten gevonden met deze filters.",
"source": "Engelse brontekst",
"missingPlaceholder": "Vertaling ontbreekt; Engels wordt gebruikt.",
"page": "Pagina {page} van {total} · {count} sleutels",
"previous": "Vorige",
"next": "Volgende",
"leaveWarning": "Je hebt niet-opgeslagen vertalingen. Deze pagina verlaten?",
"conflict": "Deze vertalingen zijn gewijzigd sinds je de editor opende. Exporteer je wijzigingen en herlaad de pagina voordat je opslaat.",
"unknownKey": "Onbekende vertaalsleutel: {key}",
"empty": "Vul een vertaling in voor {key} of verwerp deze wijziging.",
"invalid": "Ongeldige berichtsyntaxis in {key}. Controleer accolades en tekstopmaaktags.",
"variables": "Behoud dezelfde variabelenamen en tekstopmaaktags als de Engelse bron in {key}."
}
},
"hubs": {
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "ingen API-sett som nå spilles",
"noListenersHint": "ingen lyttere API satt",
"endpointDiagnostics": "Endepunktdiagnostikk",
"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> tilgjengelig · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ADVARSEL</span> ikke konfigurert · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(>-farge mislyktes] / ER-farge mislyktes ELLER.",
"diagnosticsLegend": "OK: tilgjengelig · WARN: ikke konfigurert · ERROR: forespørselen mislyktes eller fikk tidsavbrudd.",
"streamUrl": "Strøm URL",
"unreachable": "uoppnåelig",
"refresh": "Oppdater"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Det er ingen radio_stream_url satt, så det er ingenting å bygge inn ennå.",
"goToSettings": "Gå til Radioinnstillinger",
"livePreview": "Live forhåndsvisning",
"livePreviewText": "Dette er den nakne <audio>-spilleren slik besøkende vil se den. Trykk på play for å bekrefte at strømmen er tilgjengelig.",
"livePreviewText": "Dette er den nakne audio-spilleren slik besøkende vil se den. Trykk på play for å bekrefte at strømmen er tilgjengelig.",
"source": "Kilde",
"audioSnippet": "Utdrag av lydspiller",
"audioSnippetDesc": "Drop-in HTML5-lydspiller pekte rett på strømmen. Fungerer overalt hvor vanlig HTML er tillatt.",
@@ -1998,7 +1998,7 @@
"title": "Varsler",
"subtitle": "Send varsler for hele hotellet og se nylige varsler",
"sendAlert": "Send hotellvarsel",
"recentAlerts": "Nylige varsler",
"recentAlerts": "Nylige varsler ({count})",
"noRecentAlerts": "Ingen nylige varsler",
"form": {
"title": "Varseltittel",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer Tillatelser",
"importHint": "Paste a JSON array of Tillatelse objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tillatelse objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export Tillatelser",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Forhåndsinnstillinger for tema",
"presetsSubtitle": "Velg et forhåndsbygd fargevalg",
"applyPreset": "Søk",
"presetApplied": "Forhåndsinnstilling brukt",
"presetApplied": "Forhåndsinnstilling brukt: {preset}",
"customiseTitle": "Egendefinerte farger",
"primary": "Primær",
"secondary": "Sekundær",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "brak aktualnie odtwarzanego zestawu API",
"noListenersHint": "brak zestawu API odbiorników",
"endpointDiagnostics": "Diagnostyka punktów końcowych",
"diagnosticsLegend": "<span class=\"inline-block tekst-[0.72rem] czcionka-bold px-2 py-0.5 zaokrąglona-pełna bg-green-600/20 tekst-[var(--color-text)]\">OK</span> osiągalna · <span class=\"inline-block tekst-[0.72rem] czcionka-bold px-2 py-0.5 zaokrąglona-pełna bg-[var(--color-primary)]/18 tekst-[var(--color-text)]\">WARN</span> nie skonfigurowano · <span class=\"inline-block text-[0.72rem] Font-bold px-2 py-0.5 zaokrąglony-pełny bg-red-500/22 tekst-[var(--color-text)]\">BŁĄD</span> pobieranie nie powiodło się / przekroczyło czas na zewnątrz.",
"diagnosticsLegend": "OK: dostępne · WARN: nieskonfigurowane · ERROR: błąd żądania lub przekroczony limit czasu.",
"streamUrl": "Adres URL strumienia",
"unreachable": "nieosiągalny",
"refresh": "Odśwież"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Nie ma ustawionego radio_stream_url, więc nie ma jeszcze czego osadzać.",
"goToSettings": "Przejdź do Ustawień radia",
"livePreview": "Podgląd na żywo",
"livePreviewText": "To jest goły odtwarzacz <audio>, jaki zobaczą odwiedzający. Naciśnij przycisk odtwarzania, aby potwierdzić, że strumień jest osiągalny.",
"livePreviewText": "To jest goły odtwarzacz audio, jaki zobaczą odwiedzający. Naciśnij przycisk odtwarzania, aby potwierdzić, że strumień jest osiągalny.",
"source": "Źródło",
"audioSnippet": "Fragment odtwarzacza audio",
"audioSnippetDesc": "Wpuszczany odtwarzacz audio HTML5 skierowany bezpośrednio na strumień. Działa wszędzie tam, gdzie dozwolony jest zwykły HTML.",
@@ -1999,7 +1999,7 @@
"title": "Alerty",
"subtitle": "Wysyłaj alerty dotyczące całego hotelu i przeglądaj najnowsze alerty",
"sendAlert": "Wyślij alert hotelowy",
"recentAlerts": "Ostatnie alerty",
"recentAlerts": "Ostatnie alerty ({count})",
"noRecentAlerts": "Brak ostatnich alertów",
"form": {
"title": "Tytuł alertu",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importuj Uprawnienia",
"importHint": "Paste a JSON array of Pozwolenie objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Pozwolenie objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importuj",
"exportTitle": "Export Uprawnienia",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Wstępne ustawienia motywu",
"presetsSubtitle": "Wybierz gotowy schemat kolorów",
"applyPreset": "Zastosuj",
"presetApplied": "Zastosowano ustawienie wstępne",
"presetApplied": "Zastosowano ustawienie wstępne: {preset}",
"customiseTitle": "Kolory niestandardowe",
"primary": "Podstawowy",
"secondary": "Drugorzędne",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "nenhum conjunto de API em execução",
"noListenersHint": "nenhuma API de ouvinte definida",
"endpointDiagnostics": "Diagnóstico de endpoint",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 round-full bg-green-600/20 text-[var(--color-text)]\">OK</span> alcançável · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 arredondado-completo bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> não configurado · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 round-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> busca falhou/expirou o tempo limite.",
"diagnosticsLegend": "OK: acessível · WARN: não configurado · ERROR: falha no pedido ou tempo esgotado.",
"streamUrl": "URL de transmissão",
"unreachable": "inacessível",
"refresh": "Atualizar"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Não há radio_stream_url definido, então não há nada para incorporar ainda.",
"goToSettings": "Vá para configurações de rádio",
"livePreview": "Visualização ao vivo",
"livePreviewText": "Este é o player <audio> simples, como os visitantes o verão. Pressione play para confirmar que o stream está acessível.",
"livePreviewText": "Este é o player audio simples, como os visitantes o verão. Pressione play para confirmar que o stream está acessível.",
"source": "Fonte",
"audioSnippet": "Snippet do player de áudio",
"audioSnippetDesc": "Reprodutor de áudio HTML5 drop-in apontado diretamente para o stream. Funciona em qualquer lugar onde HTML simples seja permitido.",
@@ -1999,7 +1999,7 @@
"title": "Alertas",
"subtitle": "Envie alertas para todo o hotel e visualize alertas recentes",
"sendAlert": "Enviar alerta de hotel",
"recentAlerts": "Alertas recentes",
"recentAlerts": "Alertas recentes ({count})",
"noRecentAlerts": "Nenhum alerta recente",
"form": {
"title": "Título do alerta",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importar Permissões",
"importHint": "Paste a JSON array of Permissão objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permissão objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importar",
"exportTitle": "Export Permissões",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Predefinições de tema",
"presetsSubtitle": "Escolha um esquema de cores pré-construído",
"applyPreset": "Aplicar",
"presetApplied": "Predefinição aplicada",
"presetApplied": "Predefinição aplicada: {preset}",
"customiseTitle": "Cores personalizadas",
"primary": "Primário",
"secondary": "Secundário",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nici un set API care se joacă acum",
"noListenersHint": "nu se setează API pentru ascultători",
"endpointDiagnostics": "Diagnosticarea punctului final",
"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> accesibil · <span class=\"inline-block text-[0.72rem] font-bold-full-pxl.5 bg-[var(--color-primary)]/18 text-[var(--color-text)]\">AVERTISMENT</span> neconfigurat · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(]> text-[var](---> text-[var](--) eșuat-[var (---> text-[var](--)",
"diagnosticsLegend": "OK: accesibil · WARN: neconfigurat · ERROR: cerere eșuată sau timp expirat.",
"streamUrl": "Adresa URL a fluxului",
"unreachable": "de neatins",
"refresh": "Reîmprospătați"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nu există nicio setare radio_stream_url, așa că încă nu există nimic de încorporat.",
"goToSettings": "Accesați Setările radio",
"livePreview": "Previzualizare live",
"livePreviewText": "Acesta este playerul <audio> gol, așa cum îl vor vedea vizitatorii. Apăsați pe redare pentru a confirma că fluxul este accesibil.",
"livePreviewText": "Acesta este playerul audio gol, așa cum îl vor vedea vizitatorii. Apăsați pe redare pentru a confirma că fluxul este accesibil.",
"source": "Sursa",
"audioSnippet": "Fragment de player audio",
"audioSnippetDesc": "Playerul audio HTML5 integrat a îndreptat direct spre flux. Funcționează oriunde este permis HTML simplu.",
@@ -1998,7 +1998,7 @@
"title": "Alerte",
"subtitle": "Trimiteți alerte la nivel de hotel și vizualizați alertele recente",
"sendAlert": "Trimiteți o alertă la hotel",
"recentAlerts": "Alerte recente",
"recentAlerts": "Alerte recente ({count})",
"noRecentAlerts": "Nu există alerte recente",
"form": {
"title": "Titlul alertei",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Import Permisiuni",
"importHint": "Paste a JSON array of Permisiune objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permisiune objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Import",
"exportTitle": "Export Permisiuni",
@@ -2346,7 +2346,7 @@
"presetsTitle": "presetări teme",
"presetsSubtitle": "Alegeți o schemă de culori prefabricată",
"applyPreset": "Aplicați",
"presetApplied": "Presetarea aplicată",
"presetApplied": "Presetarea aplicată: {preset}",
"customiseTitle": "Culori personalizate",
"primary": "Primar",
"secondary": "Secundar",
+5 -5
View File
@@ -1707,7 +1707,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"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 · <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.",
"diagnosticsLegend": "OK: доступно · WARN: не настроено · ERROR: ошибка запроса или истекло время ожидания.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Refresh"
@@ -1766,7 +1766,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1997,7 +1997,7 @@
"title": "Alerts",
"subtitle": "Send hotel-wide alerts and view recent alerts",
"sendAlert": "Send hotel alert",
"recentAlerts": "Recent alerts",
"recentAlerts": "Recent alerts ({count})",
"noRecentAlerts": "No recent alerts",
"form": {
"title": "Alert title",
@@ -2208,7 +2208,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Импорт Разрешения",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Импорт",
"exportTitle": "Export Разрешения",
@@ -2345,7 +2345,7 @@
"presetsTitle": "Theme presets",
"presetsSubtitle": "Choose a pre-built colour scheme",
"applyPreset": "Apply",
"presetApplied": "Preset applied",
"presetApplied": "Preset applied: {preset}",
"customiseTitle": "Custom colours",
"primary": "Primary",
"secondary": "Secondary",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nie je teraz nastavené rozhranie API",
"noListenersHint": "nie je nastavené žiadne rozhranie API poslucháčov",
"endpointDiagnostics": "Diagnostika koncového bodu",
"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> dosiahnuteľné · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-primaryl]-1 bg-8 rounded text-[var(--color-text)]\">UPOZORNENIE</span> nie je nakonfigurované · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">CHYBA</span> načítanie zlyhalo / vypršal časový limit.",
"diagnosticsLegend": "OK: dostupné · WARN: nenastavené · ERROR: požiadavka zlyhala alebo vypršal časový limit.",
"streamUrl": "Webová adresa streamu",
"unreachable": "nedosiahnuteľný",
"refresh": "Obnoviť"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nie je nastavená žiadna adresa_url rádia, takže zatiaľ nie je čo vkladať.",
"goToSettings": "Prejdite do časti Nastavenia rádia",
"livePreview": "Živá ukážka",
"livePreviewText": "Toto je holý <audio> prehrávač, ako ho uvidia návštevníci. Stlačením tlačidla prehrávania potvrďte, že stream je dostupný.",
"livePreviewText": "Toto je holý audio prehrávač, ako ho uvidia návštevníci. Stlačením tlačidla prehrávania potvrďte, že stream je dostupný.",
"source": "Zdroj",
"audioSnippet": "Útržok zvukového prehrávača",
"audioSnippetDesc": "Zvukový prehrávač HTML5 nasmerovaný priamo na stream. Funguje všade, kde je povolený obyčajný HTML.",
@@ -1998,7 +1998,7 @@
"title": "Upozornenia",
"subtitle": "Odosielajte upozornenia pre celý hotel a zobrazujte posledné upozornenia",
"sendAlert": "Odoslať upozornenie na hotel",
"recentAlerts": "Nedávne upozornenia",
"recentAlerts": "Nedávne upozornenia ({count})",
"noRecentAlerts": "Žiadne nedávne upozornenia",
"form": {
"title": "Názov upozornenia",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importovať Povolenia",
"importHint": "Paste a JSON array of Povolenie objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Povolenie objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importovať",
"exportTitle": "Export Povolenia",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Prednastavenia tém",
"presetsSubtitle": "Vyberte si vopred zostavenú farebnú schému",
"applyPreset": "Použiť",
"presetApplied": "Predvoľba bola použitá",
"presetApplied": "Predvoľba bola použitá: {preset}",
"customiseTitle": "Vlastné farby",
"primary": "Primárne",
"secondary": "Sekundárne",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "нема скупа АПИ-ја који се тренутно репродукује",
"noListenersHint": "није постављен АПИ слушалаца",
"endpointDiagnostics": "Ендпоинт диагностицс",
"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)]\">ОК</span> доступан · <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)]\">УПОЗОРЕЊЕ</span> није конфигурисано · <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> преузимање није успело / време је истекло.",
"diagnosticsLegend": "OK: доступно · WARN: није подешено · ERROR: захтев није успео или је истекао.",
"streamUrl": "УРЛ стрима",
"unreachable": "недостижан",
"refresh": "Освежи"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Радио_стреам_урл није постављен, тако да још нема шта да се угради.",
"goToSettings": "Идите на Подешавања радија",
"livePreview": "Преглед уживо",
"livePreviewText": "Ово је голи <audio> плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
"livePreviewText": "Ово је голи audio плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
"source": "Извор",
"audioSnippet": "Исечак аудио плејера",
"audioSnippetDesc": "Улазни ХТМЛ5 аудио плејер усмерен право на ток. Ради свуда где је обичан ХТМЛ дозвољен.",
@@ -1998,7 +1998,7 @@
"title": "Алертс",
"subtitle": "Шаљите упозорења широм хотела и погледајте недавна упозорења",
"sendAlert": "Пошаљите хотелско обавештење",
"recentAlerts": "Недавна упозорења",
"recentAlerts": "Недавна упозорења ({count})",
"noRecentAlerts": "Нема недавних упозорења",
"form": {
"title": "Наслов упозорења",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Увоз Дозволе",
"importHint": "Paste a JSON array of Дозвола objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Дозвола objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Увоз",
"exportTitle": "Export Дозволе",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Унапред подешене теме",
"presetsSubtitle": "Choose a pre-built colour scheme",
"applyPreset": "Пријавите се",
"presetApplied": "Пресет је примењен",
"presetApplied": "Пресет је примењен: {preset}",
"customiseTitle": "Прилагођене боје",
"primary": "Примарни",
"secondary": "Секундарни",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "ingen nu-spelande API-uppsättning",
"noListenersHint": "ingen lyssnars API inställd",
"endpointDiagnostics": "Slutpunktsdiagnostik",
"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> nås · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">VARNING</span> inte konfigurerad · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text) /hämtad tid utebliven text-[var(--färg-R-text])",
"diagnosticsLegend": "OK: tillgänglig · WARN: inte konfigurerad · ERROR: begäran misslyckades eller tog för lång tid.",
"streamUrl": "Streama URL",
"unreachable": "oåtkomlig",
"refresh": "Uppdatera"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Det finns ingen radio_stream_url inställd, så det finns inget att bädda in ännu.",
"goToSettings": "Gå till Radioinställningar",
"livePreview": "Live förhandsvisning",
"livePreviewText": "Detta är den blotta <audio>-spelaren som besökare kommer att se den. Tryck på play för att bekräfta att streamen är tillgänglig.",
"livePreviewText": "Detta är den blotta audio-spelaren som besökare kommer att se den. Tryck på play för att bekräfta att streamen är tillgänglig.",
"source": "Källa",
"audioSnippet": "Utdrag från ljudspelare",
"audioSnippetDesc": "Drop-in HTML5-ljudspelare pekade rakt mot strömmen. Fungerar överallt där vanlig HTML är tillåtet.",
@@ -1998,7 +1998,7 @@
"title": "Varningar",
"subtitle": "Skicka varningar för hela hotellet och visa de senaste varningarna",
"sendAlert": "Skicka hotellvarning",
"recentAlerts": "Senaste varningar",
"recentAlerts": "Senaste varningar ({count})",
"noRecentAlerts": "Inga senaste varningar",
"form": {
"title": "Varningsrubrik",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importera Behörigheter",
"importHint": "Paste a JSON array of Tillstånd objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tillstånd objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importera",
"exportTitle": "Export Behörigheter",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Förinställda tema",
"presetsSubtitle": "Välj ett förbyggt färgschema",
"applyPreset": "Ansök",
"presetApplied": "Förinställning tillämpas",
"presetApplied": "Förinställning tillämpas: {preset}",
"customiseTitle": "Anpassade färger",
"primary": "Primär",
"secondary": "Sekundär",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "şu anda oynatılan API seti yok",
"noListenersHint": "dinleyici API seti yok",
"endpointDiagnostics": "Uç nokta teşhisi",
"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)]\">Tamam</span> ulaşılabilir · <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> yapılandırılmadı · <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> getirme başarısız oldu / zaman aşımına uğradı.",
"diagnosticsLegend": "OK: erişilebilir · WARN: yapılandırılmamış · ERROR: istek başarısız veya zaman aşımına uğradı.",
"streamUrl": "Akış URL'si",
"unreachable": "ulaşılamaz",
"refresh": "Yenile"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Radio_stream_url kümesi yok, bu nedenle henüz eklenecek bir şey yok.",
"goToSettings": "Radyo Ayarlarına Git",
"livePreview": "Canlı önizleme",
"livePreviewText": "Bu, ziyaretçilerin göreceği şekliyle çıplak <audio> oynatıcısıdır. Akışın erişilebilir olduğunu onaylamak için oynat tuşuna basın.",
"livePreviewText": "Bu, ziyaretçilerin göreceği şekliyle çıplak audio oynatıcısıdır. Akışın erişilebilir olduğunu onaylamak için oynat tuşuna basın.",
"source": "Kaynak",
"audioSnippet": "Ses oynatıcı snippet'i",
"audioSnippetDesc": "Açılan HTML5 ses oynatıcısı doğrudan akışa işaret ediyordu. Düz HTML'ye izin verilen her yerde çalışır.",
@@ -1999,7 +1999,7 @@
"title": "Uyarılar",
"subtitle": "Otel genelinde uyarılar gönderin ve son uyarıları görüntüleyin",
"sendAlert": "Otel uyarısı gönder",
"recentAlerts": "Son uyarılar",
"recentAlerts": "Son uyarılar ({count})",
"noRecentAlerts": "Yeni uyarı yok",
"form": {
"title": "Uyarı başlığı",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "İthalat İzinler",
"importHint": "Paste a JSON array of İzin objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of İzin objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "İthalat",
"exportTitle": "Export İzinler",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Tema ön ayarları",
"presetsSubtitle": "Önceden oluşturulmuş bir renk şeması seçin",
"applyPreset": "Uygula",
"presetApplied": "Ön ayar uygulandı",
"presetApplied": "Ön ayar uygulandı: {preset}",
"customiseTitle": "Özel renkler",
"primary": "Birincil",
"secondary": "İkincil",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "не налаштовано API поточного відтворення",
"noListenersHint": "API слухачів не встановлено",
"endpointDiagnostics": "Діагностика кінцевої точки",
"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> доступно · <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> не налаштовано · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ПОМИЛКА</span> вибірка не вдалася / час очікування минув.",
"diagnosticsLegend": "OK: доступно · WARN: не налаштовано · ERROR: помилка запиту або вичерпано час очікування.",
"streamUrl": "URL-адреса потоку",
"unreachable": "недоступний",
"refresh": "Оновити"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Немає параметра radio_stream_url, тому поки що немає чого вставляти.",
"goToSettings": "Перейдіть до налаштувань радіо",
"livePreview": "Попередній перегляд",
"livePreviewText": "Це простий програвач <audio>, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
"livePreviewText": "Це простий програвач audio, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
"source": "Джерело",
"audioSnippet": "Фрагмент аудіоплеєра",
"audioSnippetDesc": "Вставний аудіопрогравач HTML5 спрямований прямо на потік. Працює скрізь, де дозволено простий HTML.",
@@ -1998,7 +1998,7 @@
"title": "Оповіщення",
"subtitle": "Надсилайте сповіщення для всього готелю та переглядайте останні сповіщення",
"sendAlert": "Надіслати сповіщення готелю",
"recentAlerts": "Останні сповіщення",
"recentAlerts": "Останні сповіщення ({count})",
"noRecentAlerts": "Немає останніх сповіщень",
"form": {
"title": "Назва сповіщення",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Імпорт Дозволи",
"importHint": "Paste a JSON array of Дозвіл objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Дозвіл objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Імпорт",
"exportTitle": "Export Дозволи",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Попередні налаштування теми",
"presetsSubtitle": "Виберіть попередньо створену колірну схему",
"applyPreset": "Застосувати",
"presetApplied": "Попереднє налаштування застосовано",
"presetApplied": "Попереднє налаштування застосовано: {preset}",
"customiseTitle": "Нестандартні кольори",
"primary": "Первинний",
"secondary": "Вторинний",