fix(i18n): persist CMS translations and validate message catalogs

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

+3
View File
@@ -37,6 +37,9 @@ jobs:
- name: Lint
run: pnpm biome:lint
- name: CMS translation contracts
run: pnpm i18n:check
- name: Typecheck
run: pnpm typecheck
+30
View File
@@ -0,0 +1,30 @@
# CMS translation audit and editor
The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/<locale>.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit.
Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text.
The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save.
## Audit outcome, 6 September 2026
- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list.
- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples.
- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page.
- Completed the 31 previously missing Italian reference keys.
- Repaired missing `count` and `preset` variables in other locales.
- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls.
- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality.
- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42).
## Repeatable checks
- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI.
- `pnpm i18n:audit`: prints coverage and findings.
- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates.
Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided.
## Validation
Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session.
+7 -3
View File
@@ -30,7 +30,9 @@
"prepare": "node scripts/prepare-hooks.mjs",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "next experimental-analyze"
"analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
@@ -41,6 +43,7 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.17",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-virtual": "3.14.10",
"class-variance-authority": "0.7.1",
@@ -72,10 +75,11 @@
"sharp": "^0.35.4",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"zod": "4.5.4",
"tinymce": "8.9.0"
"tinymce": "8.9.0",
"zod": "4.5.4"
},
"devDependencies": {
"@babel/parser": "7.29.8",
"@biomejs/biome": "2.5.12",
"@playwright/test": "^1.62.1",
"@tailwindcss/forms": "0.5.11",
+6
View File
@@ -25,6 +25,9 @@ importers:
'@dnd-kit/utilities':
specifier: 3.2.2
version: 3.2.2([email protected])
'@formatjs/icu-messageformat-parser':
specifier: 3.5.17
version: 3.5.17
'@hookform/resolvers':
specifier: 5.9.1
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
@@ -125,6 +128,9 @@ importers:
specifier: 4.5.4
version: 4.5.4
devDependencies:
'@babel/parser':
specifier: 7.29.8
version: 7.29.8
'@biomejs/biome':
specifier: 2.5.12
version: 2.5.12
+189
View File
@@ -0,0 +1,189 @@
import fs from "node:fs";
import path from "node:path";
import * as babel from "@babel/parser";
import { parse } from "@formatjs/icu-messageformat-parser";
const flat = (obj, p = "", r = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = p ? `${p}.${k}` : k;
if (v && typeof v === "object") flat(v, key, r);
else r[key] = v;
}
return r;
};
const messages = Object.fromEntries(
fs
.readdirSync("src/messages")
.filter((n) => n.endsWith(".json"))
.map((n) => [
n.slice(0, -5),
flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))),
]),
);
const base = messages.en,
invalid = [],
mismatches = [];
function vars(ast, r = new Set()) {
for (const n of ast) {
if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value);
if (n.options) for (const o of Object.values(n.options)) vars(o.value, r);
if (n.children) vars(n.children, r);
}
return [...r].sort().join(",");
}
const signatures = {};
for (const [k, v] of Object.entries(base)) {
try {
signatures[k] = vars(parse(v));
} catch {}
}
const locales = Object.entries(messages).map(([locale, m]) => {
for (const [k, v] of Object.entries(m)) {
try {
const sig = vars(parse(v));
if (k in signatures && sig !== signatures[k])
mismatches.push({
locale,
key: k,
expected: signatures[k],
actual: sig,
value: v,
});
} catch (e) {
invalid.push({ locale, key: k, value: v, error: e.message });
}
}
return {
locale,
total: Object.keys(m).length,
missing: Object.keys(base).filter((k) => !(k in m)).length,
empty: Object.values(m).filter((v) => !v?.trim()).length,
sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length,
};
});
const missingCalls = [],
hardcoded = [],
unparsedFiles = [];
function walk(n, fn) {
if (!n || typeof n !== "object") return;
fn(n);
for (const [k, v] of Object.entries(n)) {
if (k === "loc" || k === "extra") continue;
if (Array.isArray(v))
v.forEach((x) => {
walk(x, fn);
});
else if (v && typeof v === "object") walk(v, fn);
}
}
for (const f of fs
.readdirSync("src", { recursive: true })
.filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) {
let ast;
try {
ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
} catch (error) {
unparsedFiles.push({ file: f, error: error.message });
continue;
}
const bindings = {};
walk(ast, (n) => {
if (n.type === "VariableDeclarator" && n.id.type === "Identifier") {
let c = n.init;
if (c?.type === "AwaitExpression") c = c.argument;
if (
c?.type === "CallExpression" &&
["useTranslations", "getTranslations"].includes(c.callee.name)
) {
const arg = c.arguments[0];
const ns =
arg?.type === "StringLiteral"
? arg.value
: arg?.type === "ObjectExpression"
? arg.properties.find((p) => p.key?.name === "namespace")?.value
?.value
: arg
? null
: "";
if (ns !== null) {
bindings[n.id.name] ??= new Set();
bindings[n.id.name].add(ns);
}
}
}
});
walk(ast, (n) => {
if (n.type === "CallExpression") {
const name = n.callee.name ?? n.callee.object?.name;
const ns = bindings[name];
const k = n.arguments[0];
if (ns?.size === 1 && k?.type === "StringLiteral") {
const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value;
if (
!(full in base) &&
!Object.keys(base).some((b) => b.startsWith(`${full}.`))
)
missingCalls.push({ file: f, line: n.loc.start.line, key: full });
}
}
if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim()))
hardcoded.push({
file: f,
line: n.loc.start.line,
text: n.value.trim().replace(/\s+/g, " "),
});
});
}
const result = {
locales,
invalid,
mismatches,
missingCalls,
hardcoded,
unparsedFiles,
};
if (process.argv.includes("--json"))
console.log(JSON.stringify(result, null, 2));
else {
console.table(locales);
console.log(
"Invalid ICU:",
invalid.length,
"Variable mismatches:",
mismatches.length,
"Missing static message references:",
missingCalls.length,
);
console.log(
"Literal JSX text candidates requiring editorial review:",
hardcoded.length,
);
console.log(
"Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.",
);
if (
invalid.length ||
mismatches.length ||
missingCalls.length ||
unparsedFiles.length
)
console.log(
JSON.stringify(
{ invalid, mismatches, missingCalls, unparsedFiles },
null,
2,
),
);
}
if (
process.argv.includes("--check") &&
(invalid.length ||
mismatches.length ||
missingCalls.length ||
unparsedFiles.length ||
locales.some((locale) => locale.empty > 0))
)
process.exitCode = 1;
+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": "Вторинний",