Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
This commit is contained in:
commit
fea8023ac6
87 files changed
+3760
-1105
No files matched your search
@@ -37,6 +37,9 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm biome:lint
|
run: pnpm biome:lint
|
||||||
|
|
||||||
|
- name: CMS translation contracts
|
||||||
|
run: pnpm i18n:check
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Existing articles remain published. Preserve any publication settings already present.
|
||||||
|
ALTER TABLE website_articles
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
|
||||||
|
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
|
||||||
|
ON website_articles (status, publish_at, created_at);
|
||||||
+7
-3
@@ -30,7 +30,9 @@
|
|||||||
"prepare": "node scripts/prepare-hooks.mjs",
|
"prepare": "node scripts/prepare-hooks.mjs",
|
||||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||||
"deps:audit": "pnpm audit --audit-level=high",
|
"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": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
"@formatjs/icu-messageformat-parser": "3.5.17",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-virtual": "3.14.10",
|
"@tanstack/react-virtual": "3.14.10",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -72,10 +75,11 @@
|
|||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"zod": "4.5.4",
|
"tinymce": "8.9.0",
|
||||||
"tinymce": "8.9.0"
|
"zod": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/parser": "7.29.8",
|
||||||
"@biomejs/biome": "2.5.12",
|
"@biomejs/biome": "2.5.12",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
|
|||||||
Generated
+6
@@ -25,6 +25,9 @@ importers:
|
|||||||
'@dnd-kit/utilities':
|
'@dnd-kit/utilities':
|
||||||
specifier: 3.2.2
|
specifier: 3.2.2
|
||||||
version: 3.2.2([email protected])
|
version: 3.2.2([email protected])
|
||||||
|
'@formatjs/icu-messageformat-parser':
|
||||||
|
specifier: 3.5.17
|
||||||
|
version: 3.5.17
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.9.1
|
specifier: 5.9.1
|
||||||
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -125,6 +128,9 @@ importers:
|
|||||||
specifier: 4.5.4
|
specifier: 4.5.4
|
||||||
version: 4.5.4
|
version: 4.5.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@babel/parser':
|
||||||
|
specifier: 7.29.8
|
||||||
|
version: 7.29.8
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.12
|
specifier: 2.5.12
|
||||||
version: 2.5.12
|
version: 2.5.12
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger";
|
|||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||||
|
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||||
import { rcon } from "../src/lib/services/rcon";
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
@@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
lte(WebsiteArticles.publishAt, now),
|
lte(WebsiteArticles.publishAt, now),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const info = result as unknown as {
|
const [info] = result;
|
||||||
affectedRows?: number;
|
const published = info.affectedRows ?? 0;
|
||||||
rowsAffected?: number;
|
|
||||||
};
|
|
||||||
const published = info.affectedRows ?? info.rowsAffected ?? 0;
|
|
||||||
if (published > 0) {
|
if (published > 0) {
|
||||||
|
await invalidateNewsCache();
|
||||||
logger.info(`Published ${published} scheduled article(s)`, {
|
logger.info(`Published ${published} scheduled article(s)`, {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rows: [] as unknown[][],
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
log: vi.fn(() => "news-error-1"),
|
||||||
|
notify: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||||
|
vi.mock("@/lib/services/news-cache", () => ({
|
||||||
|
invalidateNewsCache: state.invalidate,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations: async () => (key: string, args?: { reference: string }) =>
|
||||||
|
key + (args?.reference ?? ""),
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
throw Error(`redirect ${url}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => ({
|
||||||
|
...(await import("@/db/schema")),
|
||||||
|
db: {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: state.insert }),
|
||||||
|
update: () => ({
|
||||||
|
set: (value: unknown) => {
|
||||||
|
state.update(value);
|
||||||
|
return { where: async () => [{ affectedRows: 1 }] };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createArticle, updateArticle } from "./admin-articles";
|
||||||
|
|
||||||
|
function form(extra: Record<string, string> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries({
|
||||||
|
title: "Test news",
|
||||||
|
slug: "test-news",
|
||||||
|
shortStory: "Summary",
|
||||||
|
fullStory: "Body",
|
||||||
|
status: "published",
|
||||||
|
...extra,
|
||||||
|
}))
|
||||||
|
f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
describe("news saves", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.rows = [];
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
});
|
||||||
|
it("preserves a recoverable error and logs its reference", async () => {
|
||||||
|
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
|
||||||
|
const result = await createArticle(form());
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "saveFailedReferencenews-error-1",
|
||||||
|
});
|
||||||
|
expect(state.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(state.log).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("saves drafts without publication notifications", async () => {
|
||||||
|
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
|
||||||
|
expect(state.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "draft",
|
||||||
|
publishAt: null,
|
||||||
|
publishedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.notify).not.toHaveBeenCalled();
|
||||||
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||||
|
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await updateArticle(
|
||||||
|
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "test-news",
|
||||||
|
publishAt: null,
|
||||||
|
status: "published",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it("rejects invalid scheduled dates before writing", async () => {
|
||||||
|
expect(
|
||||||
|
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+98
-103
@@ -1,9 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
ArticleInputError,
|
||||||
|
type ArticleSaveResult,
|
||||||
|
readArticleInput,
|
||||||
|
} from "@/lib/article-input";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsiteArticleComments,
|
WebsiteArticleComments,
|
||||||
@@ -12,152 +18,139 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
|
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||||
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
|
|
||||||
|
|
||||||
function parseArticleStatus(raw: unknown): ArticleStatus {
|
|
||||||
const value = String(raw ?? "published").trim();
|
|
||||||
return (ARTICLE_STATUSES as readonly string[]).includes(value)
|
|
||||||
? (value as ArticleStatus)
|
|
||||||
: "published";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePublishAt(raw: unknown): Date | null {
|
|
||||||
const value = String(raw ?? "").trim();
|
|
||||||
if (!value) return null;
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let n = 2;
|
for (let n = 2; ; n++) {
|
||||||
for (;;) {
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: WebsiteArticles.id })
|
.select({ id: WebsiteArticles.id })
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing) return slug;
|
if (!existing) return slug;
|
||||||
slug = `${base}-${n++}`;
|
const suffix = `-${n}`;
|
||||||
|
slug = base.slice(0, 255 - suffix.length) + suffix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function saveFailure(
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
): Promise<ArticleSaveResult> {
|
||||||
|
const t = await getTranslations("pages.admin.articles.form");
|
||||||
|
if (error instanceof ArticleInputError)
|
||||||
|
return { ok: false, error: t(error.code) };
|
||||||
|
const reference = logger.error("News save failed", {
|
||||||
|
module: "news",
|
||||||
|
operation,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return { ok: false, error: t("saveFailedReference", { reference }) };
|
||||||
|
}
|
||||||
|
async function refreshNews() {
|
||||||
|
await invalidateNewsCache();
|
||||||
|
revalidatePath("/admin/articles");
|
||||||
|
revalidatePath("/news", "layout");
|
||||||
|
revalidatePath("/me");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
export async function createArticle(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ArticleSaveResult> {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const title = String(formData.get("title") ?? "")
|
let input: ReturnType<typeof readArticleInput>;
|
||||||
.normalize("NFC")
|
let slug: string;
|
||||||
.trim();
|
|
||||||
const shortStory = String(formData.get("shortStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const fullStory = String(formData.get("fullStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const image = String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
|
||||||
const status = parseArticleStatus(formData.get("status"));
|
|
||||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
|
||||||
if (!title) return;
|
|
||||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
||||||
redirect(
|
|
||||||
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
input = readArticleInput(formData);
|
||||||
|
const { rawSlug, ...fields } = input;
|
||||||
|
slug = await uniqueSlug(rawSlug || fields.title);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const publishAt = parsePublishAt(rawPublishAt);
|
|
||||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
|
||||||
await db.insert(WebsiteArticles).values({
|
await db.insert(WebsiteArticles).values({
|
||||||
|
...fields,
|
||||||
slug,
|
slug,
|
||||||
title: title.slice(0, 255),
|
|
||||||
shortStory: shortStory.slice(0, 255),
|
|
||||||
fullStory,
|
|
||||||
image: image.slice(0, 255),
|
|
||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status,
|
publishedAt: fields.status === "published" ? now : null,
|
||||||
publishAt,
|
|
||||||
publishedAt: status === "published" ? now : null,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return saveFailure(error, "create");
|
||||||
|
}
|
||||||
|
// Auxiliary services must not turn a committed insert into an apparent failure.
|
||||||
|
if (input.status === "published")
|
||||||
notify({
|
notify({
|
||||||
action: "news_publish",
|
action: "news_publish",
|
||||||
actor: staff.username,
|
actor: staff.username,
|
||||||
target: title,
|
target: input.title,
|
||||||
details: slug,
|
details: slug,
|
||||||
});
|
});
|
||||||
} catch {
|
await refreshNews();
|
||||||
// Database error — re-render unchanged with error.
|
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||||
redirect(
|
|
||||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
redirect("/admin/articles");
|
|
||||||
}
|
}
|
||||||
|
export async function updateArticle(
|
||||||
export async function updateArticle(formData: FormData): Promise<void> {
|
formData: FormData,
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
): Promise<ArticleSaveResult> {
|
||||||
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
|
const t = await getTranslations("pages.admin.articles.form");
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) redirect("/admin/articles?error=Missing article id");
|
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
let input: ReturnType<typeof readArticleInput>;
|
||||||
const status = parseArticleStatus(formData.get("status"));
|
let becamePublished = false;
|
||||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
let slug: string;
|
||||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
||||||
redirect(
|
|
||||||
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const publishAt = parsePublishAt(rawPublishAt);
|
input = readArticleInput(formData);
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({
|
.select({
|
||||||
|
slug: WebsiteArticles.slug,
|
||||||
status: WebsiteArticles.status,
|
status: WebsiteArticles.status,
|
||||||
publishedAt: WebsiteArticles.publishedAt,
|
publishedAt: WebsiteArticles.publishedAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.id, id))
|
.where(eq(WebsiteArticles.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const publishedAt =
|
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||||
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
|
const { rawSlug, ...fields } = input;
|
||||||
|
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||||
|
slug =
|
||||||
|
requestedSlug === existing.slug
|
||||||
|
? existing.slug
|
||||||
|
: await uniqueSlug(requestedSlug, id);
|
||||||
|
becamePublished =
|
||||||
|
fields.status === "published" && existing.status !== "published";
|
||||||
await db
|
await db
|
||||||
.update(WebsiteArticles)
|
.update(WebsiteArticles)
|
||||||
.set({
|
.set({
|
||||||
title: String(formData.get("title") ?? "")
|
...fields,
|
||||||
.normalize("NFC")
|
slug,
|
||||||
.trim()
|
publishedAt:
|
||||||
.slice(0, 255),
|
fields.status === "published"
|
||||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
? (existing.publishedAt ?? new Date())
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
: null,
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
fullStory: String(formData.get("fullStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim(),
|
|
||||||
image: String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
status,
|
|
||||||
publishAt,
|
|
||||||
publishedAt,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(WebsiteArticles.id, id));
|
.where(eq(WebsiteArticles.id, id));
|
||||||
} catch {
|
} catch (error) {
|
||||||
redirect("/admin/articles?error=Update failed");
|
return saveFailure(error, "update");
|
||||||
}
|
}
|
||||||
|
if (becamePublished)
|
||||||
|
notify({
|
||||||
|
action: "news_publish",
|
||||||
|
actor: staff.username,
|
||||||
|
target: input.title,
|
||||||
|
details: slug,
|
||||||
|
});
|
||||||
|
await refreshNews();
|
||||||
revalidatePath(`/admin/articles/${id}`);
|
revalidatePath(`/admin/articles/${id}`);
|
||||||
redirect("/admin/articles");
|
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
@@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
actor: staff.username,
|
actor: staff.username,
|
||||||
target: article?.title ?? String(id),
|
target: article?.title ?? String(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("News deletion failed", { module: "news", error });
|
||||||
redirect("/admin/articles?error=Delete failed");
|
redirect("/admin/articles?error=Delete failed");
|
||||||
}
|
}
|
||||||
|
await refreshNews();
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
+25
-38
@@ -3,60 +3,47 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as JSONC from "jsonc-parser";
|
import * as JSONC from "jsonc-parser";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { SUPPORTED_LOCALES } from "@/i18n/locales";
|
||||||
import {
|
import {
|
||||||
CLIENT_TRANSLATION_FILES,
|
CLIENT_TRANSLATION_FILES,
|
||||||
getClientTranslationFile,
|
getClientTranslationFile,
|
||||||
} from "@/lib/client-translation-files";
|
} from "@/lib/client-translation-files";
|
||||||
|
import {
|
||||||
|
CmsTranslationError,
|
||||||
|
saveCmsTranslation,
|
||||||
|
} from "@/lib/cms-translations";
|
||||||
import { patchJson5 } from "@/lib/json5-patch";
|
import { patchJson5 } from "@/lib/json5-patch";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
const saveTranslationsSchema = z.object({
|
const saveTranslationsSchema = z.object({
|
||||||
locale: z.enum([
|
locale: z.enum(SUPPORTED_LOCALES),
|
||||||
"en",
|
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
"it",
|
changes: z
|
||||||
"nl",
|
.record(z.string().max(300), z.string().max(20000).nullable())
|
||||||
"de",
|
.refine((value) => Object.keys(value).length <= 5000),
|
||||||
"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()),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveTranslations = adminAction(
|
export const saveTranslations = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const filePath = path.join(
|
try {
|
||||||
process.cwd(),
|
const snapshot = await saveCmsTranslation(
|
||||||
"src",
|
ctx.data.locale,
|
||||||
"messages",
|
ctx.data.revision,
|
||||||
`${ctx.data.locale}.json`,
|
ctx.data.changes,
|
||||||
);
|
);
|
||||||
await fs.writeFile(
|
revalidatePath("/", "layout");
|
||||||
filePath,
|
return actionOk({ snapshot });
|
||||||
JSON.stringify(ctx.data.data, null, 2),
|
} catch (error) {
|
||||||
"utf-8",
|
if (!(error instanceof CmsTranslationError)) throw error;
|
||||||
);
|
const t = await getTranslations("pages.admin.translations.cms");
|
||||||
|
return { ok: false, error: t(error.code, { key: error.key }) };
|
||||||
return actionOk();
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -99,13 +99,7 @@ export default async function MePage({
|
|||||||
);
|
);
|
||||||
const canClaim = referralTotal >= needed;
|
const canClaim = referralTotal >= needed;
|
||||||
const remaining = Math.max(0, needed - referralTotal);
|
const remaining = Math.max(0, needed - referralTotal);
|
||||||
const onlineFriends = [
|
const onlineFriends = friends.filter((friend) => friend.online === "1");
|
||||||
...new Map(
|
|
||||||
friends
|
|
||||||
.filter((friend) => friend.online === "1")
|
|
||||||
.map((friend) => [friend.id, friend]),
|
|
||||||
).values(),
|
|
||||||
];
|
|
||||||
const date = (value: number) =>
|
const date = (value: number) =>
|
||||||
value
|
value
|
||||||
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
|
|||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
|
import { articleSlug } from "@/lib/article-input";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cachedQuery } from "@/lib/cached-db";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
User,
|
User,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
comment?: string;
|
comment?: string;
|
||||||
@@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
|||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string[] }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||||
const { slug } = await params;
|
const slug = articleSlug((await params).slug);
|
||||||
const [article] = await db
|
const [article] = await db
|
||||||
.select({
|
.select({
|
||||||
title: WebsiteArticles.title,
|
title: WebsiteArticles.title,
|
||||||
@@ -95,44 +96,40 @@ export default async function ArticlePage({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string[] }>;
|
||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const slug = articleSlug((await params).slug);
|
||||||
const { comment, reaction, error } = await searchParams;
|
const { comment, reaction, error } = await searchParams;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
const article = await cachedQuery(
|
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
|
||||||
`article:slug:${slug}`,
|
const [row] = await db
|
||||||
async () => {
|
.select({
|
||||||
const [row] = await db
|
id: WebsiteArticles.id,
|
||||||
.select({
|
slug: WebsiteArticles.slug,
|
||||||
id: WebsiteArticles.id,
|
title: WebsiteArticles.title,
|
||||||
slug: WebsiteArticles.slug,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
title: WebsiteArticles.title,
|
fullStory: WebsiteArticles.fullStory,
|
||||||
shortStory: WebsiteArticles.shortStory,
|
image: WebsiteArticles.image,
|
||||||
fullStory: WebsiteArticles.fullStory,
|
createdAt: WebsiteArticles.createdAt,
|
||||||
image: WebsiteArticles.image,
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
createdAt: WebsiteArticles.createdAt,
|
})
|
||||||
updatedAt: WebsiteArticles.updatedAt,
|
.from(WebsiteArticles)
|
||||||
})
|
.where(
|
||||||
.from(WebsiteArticles)
|
and(
|
||||||
.where(
|
eq(WebsiteArticles.slug, slug),
|
||||||
and(
|
eq(WebsiteArticles.status, "published"),
|
||||||
eq(WebsiteArticles.slug, slug),
|
or(
|
||||||
eq(WebsiteArticles.status, "published"),
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
or(
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
|
||||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.limit(1)
|
)
|
||||||
.catch(() => []);
|
.limit(1)
|
||||||
return row ?? null;
|
.catch(() => []);
|
||||||
},
|
return row ?? null;
|
||||||
60,
|
});
|
||||||
);
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
const articleId = article.id;
|
const articleId = article.id;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default async function EditArticle({
|
|||||||
title: WebsiteArticles.title,
|
title: WebsiteArticles.title,
|
||||||
shortStory: WebsiteArticles.shortStory,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
fullStory: WebsiteArticles.fullStory,
|
fullStory: WebsiteArticles.fullStory,
|
||||||
|
status: WebsiteArticles.status,
|
||||||
|
publishAt: WebsiteArticles.publishAt,
|
||||||
image: WebsiteArticles.image,
|
image: WebsiteArticles.image,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
@@ -63,7 +65,7 @@ export default async function EditArticle({
|
|||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
"use server";
|
"use server";
|
||||||
fd.set("id", String(article.id));
|
fd.set("id", String(article.id));
|
||||||
await updateArticle(fd);
|
return updateArticle(fd);
|
||||||
}}
|
}}
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
title: article.title,
|
title: article.title,
|
||||||
@@ -71,6 +73,8 @@ export default async function EditArticle({
|
|||||||
image: article.image,
|
image: article.image,
|
||||||
shortStory: article.shortStory,
|
shortStory: article.shortStory,
|
||||||
fullStory: article.fullStory,
|
fullStory: article.fullStory,
|
||||||
|
status: article.status,
|
||||||
|
publishAt: article.publishAt?.toISOString(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ErrorStore } from "@/lib/error-monitor";
|
import { ErrorStore } from "@/lib/error-monitor";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
@@ -11,6 +12,7 @@ export default async function CmsErrorsPage({
|
|||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
|
const t = await getTranslations("pages.admin.cmsErrors");
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||||
@@ -19,14 +21,15 @@ export default async function CmsErrorsPage({
|
|||||||
} catch {
|
} catch {
|
||||||
return (
|
return (
|
||||||
<div className="admin-card" role="alert">
|
<div className="admin-card" role="alert">
|
||||||
Error storage could not be read. Check the CMS storage volume and
|
{t("storageError")}
|
||||||
permissions.
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const filtered = result.records.filter(
|
const filtered = result.records.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
(!params.source || r.source === params.source) &&
|
(!params.source || r.source === params.source) &&
|
||||||
|
(params.status !== "open" || !r.resolved) &&
|
||||||
|
(params.status !== "resolved" || r.resolved) &&
|
||||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||||
);
|
);
|
||||||
const groups = new Map<string, typeof filtered>();
|
const groups = new Map<string, typeof filtered>();
|
||||||
@@ -43,62 +46,62 @@ export default async function CmsErrorsPage({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const pageHref = (next: number) =>
|
const pageHref = (next: number) =>
|
||||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
|
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">CMS error center</h1>
|
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||||
Server and browser errors from the last 7 days. Browser reports are
|
|
||||||
unverified client observations.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link href="/admin/devops" className="btn btn-outline">
|
<Link href="/admin/devops" className="btn btn-outline">
|
||||||
DevOps
|
DevOps
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/devops/errors" className="btn btn-outline">
|
<Link href="/admin/devops/errors" className="btn btn-outline">
|
||||||
Emulator errors
|
{t("emulator")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||||
Refresh / clear filters
|
{t("refresh")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<form className="admin-card flex flex-wrap gap-3">
|
<form className="admin-card flex flex-wrap gap-3">
|
||||||
<input
|
<input
|
||||||
aria-label="Search errors"
|
aria-label={t("search")}
|
||||||
name="q"
|
name="q"
|
||||||
defaultValue={params.q}
|
defaultValue={params.q}
|
||||||
placeholder="Reference, digest, message, route or release"
|
placeholder={t("searchHint")}
|
||||||
className="input min-w-0 flex-1"
|
className="input min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
name="source"
|
name="source"
|
||||||
aria-label="Error source"
|
aria-label={t("source")}
|
||||||
defaultValue={params.source ?? ""}
|
defaultValue={params.source ?? ""}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="">All sources</option>
|
<option value="">{t("allSources")}</option>
|
||||||
<option value="server">Server</option>
|
<option value="server">Server</option>
|
||||||
<option value="browser">Browser</option>
|
<option value="browser">Browser</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
aria-label={t("status")}
|
||||||
|
defaultValue={params.status ?? ""}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">{t("allStatuses")}</option>
|
||||||
|
<option value="open">{t("open")}</option>
|
||||||
|
<option value="resolved">{t("resolved")}</option>
|
||||||
|
</select>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Search
|
{t("search")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filtered.length} occurrences · {groups.size} groups.{" "}
|
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
|
||||||
{result.truncated
|
{result.truncated ? t("truncated") : ""} {t("retention")}
|
||||||
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
|
|
||||||
: ""}{" "}
|
|
||||||
Storage is limited to 10 MB per day; excess events remain in server logs
|
|
||||||
where available.
|
|
||||||
</p>
|
</p>
|
||||||
{groups.size === 0 ? (
|
{groups.size === 0 ? (
|
||||||
<div className="admin-card">
|
<div className="admin-card">{t("empty")}</div>
|
||||||
No captured errors match these filters. Monitoring starts with this
|
|
||||||
deployment; older console logs are not imported.
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{[...groups.entries()]
|
{[...groups.entries()]
|
||||||
.slice((page - 1) * 25, page * 25)
|
.slice((page - 1) * 25, page * 25)
|
||||||
@@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
|
|||||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||||
<summary className="cursor-pointer">
|
<summary className="cursor-pointer">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
|
{t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "}
|
||||||
{events.length} occurrences · {r.at}
|
{t("occurrences", { count: events.length })} · {r.at}
|
||||||
</span>
|
</span>
|
||||||
<p className="font-semibold break-words">{r.event}</p>
|
<p className="font-semibold break-words">{r.event}</p>
|
||||||
<p className="text-sm break-words">{r.message}</p>
|
<p className="text-sm break-words">{r.message}</p>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-4 space-y-3 text-sm">
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
<p>
|
<p>
|
||||||
Reference: <code className="break-all">{r.id}</code>
|
{t("reference")}: <code className="break-all">{r.id}</code>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Release: <code className="break-all">{r.release}</code>
|
{t("release")}: <code className="break-all">{r.release}</code>
|
||||||
</p>
|
</p>
|
||||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
|
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||||
{JSON.stringify(r.context, null, 2)}
|
{JSON.stringify(r.context, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||||
{r.stack ||
|
{r.stack || t("noStack")}
|
||||||
"No stack supplied. Use the reference or digest to correlate server logs."}
|
|
||||||
</pre>
|
</pre>
|
||||||
{!r.resolved &&
|
{!r.resolved &&
|
||||||
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
|
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
|
||||||
@@ -137,16 +139,16 @@ export default async function CmsErrorsPage({
|
|||||||
value={r.fingerprint}
|
value={r.fingerprint}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-outline">
|
<button type="submit" className="btn btn-outline">
|
||||||
Mark resolved
|
{t("resolve")}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
A new occurrence reopens the group automatically.
|
{t("reopenHint")}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
<details>
|
<details>
|
||||||
<summary className="cursor-pointer">
|
<summary className="cursor-pointer">
|
||||||
Occurrence references
|
{t("references")}
|
||||||
</summary>
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
@@ -156,23 +158,20 @@ export default async function CmsErrorsPage({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("stackHint")}</p>
|
||||||
Browser stack traces may reference compiled chunks. This view
|
|
||||||
does not infer a root cause or include request bodies.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<nav aria-label="Error pages" className="flex gap-3">
|
<nav aria-label={t("pages")} className="flex gap-3">
|
||||||
{page > 1 ? (
|
{page > 1 ? (
|
||||||
<Link href={pageHref(page - 1)} className="btn btn-outline">
|
<Link href={pageHref(page - 1)} className="btn btn-outline">
|
||||||
Previous
|
{t("previous")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{page * 25 < groups.size ? (
|
{page * 25 < groups.size ? (
|
||||||
<Link href={pageHref(page + 1)} className="btn btn-outline">
|
<Link href={pageHref(page + 1)} className="btn btn-outline">
|
||||||
Next
|
{t("next")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default async function DevOpsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||||
CMS error center
|
{t("cmsErrors")}
|
||||||
</a>
|
</a>
|
||||||
{/* Status cards */}
|
{/* Status cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function AdminErrorPage({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.error");
|
const t = useTranslations("pages.error");
|
||||||
const tNav = useTranslations("admin.nav");
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
|
|
||||||
const [reference, setReference] = useState(error.digest);
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
|||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
import { loadAdminNavigation } from "@/lib/admin/navigation";
|
||||||
import {
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
parseAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { readCsrfCookieToken } from "@/lib/foundation/security";
|
import { readCsrfCookieToken } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
|
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
|
||||||
@@ -31,6 +26,7 @@ export default async function AdminLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
|
const navigation = await loadAdminNavigation();
|
||||||
const csrfToken = await readCsrfCookieToken();
|
const csrfToken = await readCsrfCookieToken();
|
||||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||||
const u = await db
|
const u = await db
|
||||||
@@ -52,7 +48,7 @@ export default async function AdminLayout({
|
|||||||
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
|
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
|
||||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
<AdminTopbar staff={staff} />
|
<AdminTopbar staff={staff} navigation={navigation} />
|
||||||
<section className="admin-page flex-1">
|
<section className="admin-page flex-1">
|
||||||
<AdminHubChrome>{children}</AdminHubChrome>
|
<AdminHubChrome>{children}</AdminHubChrome>
|
||||||
</section>
|
</section>
|
||||||
@@ -69,27 +65,8 @@ async function Sidebar({
|
|||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.admin.nav");
|
const t = await getTranslations("pages.admin.nav");
|
||||||
const initial = staff.username.charAt(0).toUpperCase();
|
const initial = staff.username.charAt(0).toUpperCase();
|
||||||
const { permissions } = await getAdminContext();
|
const { allowedPermissions, isSuperAdmin, navConfig } =
|
||||||
const isSuperAdmin = permissions.isSuperAdmin;
|
await loadAdminNavigation();
|
||||||
const hasDashboard = canAccess(
|
|
||||||
permissions,
|
|
||||||
PERMS.ADMIN_DASHBOARD,
|
|
||||||
staff.rank,
|
|
||||||
);
|
|
||||||
// Super-admins see everything. Everyone else sees items their ACL grants.
|
|
||||||
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
|
|
||||||
// migration gaps), still show *.view nav entries so categories do not vanish.
|
|
||||||
const allowedPermissions = isSuperAdmin
|
|
||||||
? []
|
|
||||||
: collectNavPermissionSlugs().filter((slug) => {
|
|
||||||
if (canAccess(permissions, slug, staff.rank)) return true;
|
|
||||||
if (!hasDashboard) return false;
|
|
||||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
|
||||||
});
|
|
||||||
|
|
||||||
const navConfig = parseAdminNavConfig(
|
|
||||||
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminWorkQueue } from "@/components/admin/admin-work-queue";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { unixNow } from "@/lib/bans";
|
import { unixNow } from "@/lib/bans";
|
||||||
@@ -126,6 +127,7 @@ export default async function AdminDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AdminWorkQueue />
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<section className="admin-card p-5">
|
<section className="admin-card p-5">
|
||||||
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
|
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
|
||||||
import { CatalogGitConfiguration } from "./catalog-git-configuration";
|
import { CatalogGitConfiguration } from "./catalog-git-configuration";
|
||||||
@@ -25,7 +26,10 @@ export function CatalogExportClient({
|
|||||||
const [status, setStatus] = useState<ExportStatus | null>(null);
|
const [status, setStatus] = useState<ExportStatus | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const refreshing = useRef(false);
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (refreshing.current) return;
|
||||||
|
refreshing.current = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminFetch("/api/admin/catalog-export", {
|
const response = await adminFetch("/api/admin/catalog-export", {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -35,15 +39,11 @@ export function CatalogExportClient({
|
|||||||
setError("");
|
setError("");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Unable to load export status");
|
setError("Unable to load export status");
|
||||||
|
} finally {
|
||||||
|
refreshing.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useVisiblePolling(refresh, 15000);
|
||||||
void refresh();
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
void refresh();
|
|
||||||
}, 15_000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [refresh]);
|
|
||||||
async function retry() {
|
async function retry() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,48 +1,24 @@
|
|||||||
import fs from "node:fs/promises";
|
import { getLocale } from "next-intl/server";
|
||||||
import path from "node:path";
|
import { SUPPORTED_LOCALES } from "@/i18n/locales";
|
||||||
|
import { readCmsTranslation } from "@/lib/cms-translations";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { TranslationsClient } from "../translations-client";
|
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() {
|
export default async function CmsTranslationsPage() {
|
||||||
const messagesDir = path.join(process.cwd(), "src", "messages");
|
const [{ session, permissions }, locale, entries] = await Promise.all([
|
||||||
|
getAdminContext(),
|
||||||
const contents: Record<string, string> = {};
|
getLocale(),
|
||||||
await Promise.all(
|
Promise.all(
|
||||||
LOCALES.map(async (locale) => {
|
SUPPORTED_LOCALES.map(
|
||||||
try {
|
async (locale) => [locale, await readCmsTranslation(locale)] as const,
|
||||||
contents[locale] = await fs.readFile(
|
),
|
||||||
path.join(messagesDir, `${locale}.json`),
|
),
|
||||||
"utf-8",
|
]);
|
||||||
);
|
return (
|
||||||
} catch {
|
<TranslationsClient
|
||||||
// Skip missing locale files instead of failing the whole page.
|
snapshots={Object.fromEntries(entries)}
|
||||||
}
|
initialLocale={locale}
|
||||||
}),
|
canEdit={canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TranslationsClient localeContents={contents} />;
|
|
||||||
}
|
}
|
||||||
@@ -1,132 +1,127 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Save } from "lucide-react";
|
import { Download, Save } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { saveTranslations } from "@/actions/translations";
|
import { saveTranslations } from "@/actions/translations";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { flattenObject, unflattenObject } from "@/lib/translations-utils";
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
|
import {
|
||||||
type Locale =
|
type AppLocale,
|
||||||
| "en"
|
isSupportedLocale,
|
||||||
| "it"
|
SUPPORTED_LOCALES,
|
||||||
| "nl"
|
} from "@/i18n/locales";
|
||||||
| "de"
|
import { validateCmsMessage } from "@/lib/cms-message-validation";
|
||||||
| "fr"
|
import type { CmsTranslationSnapshot } from "@/lib/cms-translations";
|
||||||
| "es"
|
import { unflattenObject } from "@/lib/translations-utils";
|
||||||
| "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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
localeContents: Record<string, string>;
|
snapshots: Record<string, CmsTranslationSnapshot>;
|
||||||
|
initialLocale: string;
|
||||||
|
canEdit: boolean;
|
||||||
}
|
}
|
||||||
|
const PER_PAGE = 30;
|
||||||
export function TranslationsClient({ localeContents }: Props) {
|
export function TranslationsClient({
|
||||||
const [data, setData] = useState<Record<string, Record<string, string>>>(
|
snapshots,
|
||||||
() => {
|
initialLocale,
|
||||||
const initial: Record<string, Record<string, string>> = {};
|
canEdit,
|
||||||
for (const [locale, content] of Object.entries(localeContents)) {
|
}: Props) {
|
||||||
initial[locale] = flattenObject(JSON.parse(content));
|
const t = useTranslations("pages.admin.translations.cms");
|
||||||
}
|
const router = useRouter();
|
||||||
return initial;
|
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 [search, setSearch] = useState("");
|
||||||
const [group, setGroup] = useState("");
|
const [group, setGroup] = useState("");
|
||||||
const [activeLang, setActiveLang] = useState<Locale>("en");
|
const [status, setStatus] = useState("all");
|
||||||
const { run, isPending } = useServerAction();
|
|
||||||
const PER_PAGE = 30;
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const { run, isPending } = useServerAction();
|
||||||
const activeData = data[activeLang];
|
const source = saved.en.messages;
|
||||||
|
const activeData = saved[activeLang].messages;
|
||||||
const groups = useMemo(() => {
|
const draft = drafts[activeLang] ?? {};
|
||||||
const g = new Set<string>();
|
const keys = useMemo(() => Object.keys(source).sort(), [source]);
|
||||||
Object.keys(data.en ?? {}).forEach((k) => {
|
const groups = useMemo(
|
||||||
const parts = k.split(".");
|
() => [...new Set(keys.map((key) => key.split(".")[0]))],
|
||||||
if (parts.length > 1) g.add(parts[0]);
|
[keys],
|
||||||
});
|
);
|
||||||
return Array.from(g).sort();
|
const dirty = Object.values(drafts).some(
|
||||||
}, [data]);
|
(values) => Object.keys(values).length > 0,
|
||||||
|
);
|
||||||
const filteredKeys = useMemo(() => {
|
useUnsavedChanges(dirty, t("leaveWarning"));
|
||||||
if (!activeData) return [];
|
const valueFor = (key: string) => draft[key] ?? activeData[key] ?? "";
|
||||||
return Object.keys(activeData).filter((key) => {
|
const missing = keys.filter((key) => !valueFor(key).trim()).length;
|
||||||
const matchSearch =
|
const identical =
|
||||||
!search ||
|
activeLang === "en"
|
||||||
key.toLowerCase().includes(search.toLowerCase()) ||
|
? 0
|
||||||
activeData[key].toLowerCase().includes(search.toLowerCase());
|
: keys.filter((key) => valueFor(key) === source[key]).length;
|
||||||
const matchGroup = !group || key.startsWith(`${group}.`);
|
const errors: Record<string, string> = {};
|
||||||
return matchSearch && matchGroup;
|
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() {
|
function handleSave() {
|
||||||
|
if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length)
|
||||||
|
return;
|
||||||
|
const locale = activeLang;
|
||||||
run(
|
run(
|
||||||
() =>
|
() =>
|
||||||
saveTranslations({
|
saveTranslations({
|
||||||
locale: activeLang,
|
locale,
|
||||||
data: unflattenObject(activeData),
|
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() {
|
function handleExport() {
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[JSON.stringify(unflattenObject(activeData), null, 2)],
|
[JSON.stringify(unflattenObject({ ...activeData, ...draft }), null, 2)],
|
||||||
{
|
{ type: "application/json" },
|
||||||
type: "application/json",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -135,106 +130,213 @@ export function TranslationsClient({ localeContents }: Props) {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex gap-2">
|
<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}>
|
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-1" /> Export
|
<Download className="mr-1 h-4 w-4" />
|
||||||
</Button>
|
{t("export")}
|
||||||
<Button size="sm" onClick={handleSave} disabled={isPending}>
|
|
||||||
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
{!canEdit && (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<p className="text-sm text-muted-foreground">{t("readOnly")}</p>
|
||||||
<div className="flex gap-1 overflow-x-auto pb-1 max-w-full">
|
)}
|
||||||
{LOCALES.map(({ code, label }) => (
|
<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
|
<Button
|
||||||
key={code}
|
key={code}
|
||||||
variant={activeLang === code ? "default" : "outline"}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="shrink-0"
|
variant={activeLang === code ? "default" : "outline"}
|
||||||
|
aria-pressed={activeLang === code}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLang(code);
|
setActiveLang(code);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{code.toUpperCase()}
|
||||||
|
{Object.keys(drafts[code] ?? {}).length ? " *" : ""}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</fieldset>
|
||||||
<Input
|
<p className="text-sm" role="status">
|
||||||
placeholder="Filter by key or value..."
|
{t("coverage", { total: keys.length, missing, identical })}
|
||||||
value={search}
|
</p>
|
||||||
onChange={(e) => {
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
setSearch(e.target.value);
|
<label
|
||||||
setPage(1);
|
htmlFor="cms-translation-search"
|
||||||
}}
|
className="grid w-full min-w-0 gap-1 text-sm md:w-auto md:flex-1"
|
||||||
className="max-w-xs"
|
>
|
||||||
/>
|
{t("search")}
|
||||||
<select
|
|
||||||
className="rounded-md border bg-background px-3 py-1 text-sm"
|
|
||||||
value={group}
|
|
||||||
onChange={(e) => {
|
|
||||||
setGroup(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">All groups</option>
|
|
||||||
{groups.map((g) => (
|
|
||||||
<option key={g} value={g}>
|
|
||||||
{g}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="text-sm text-muted-foreground self-center">
|
|
||||||
{filteredKeys.length} keys
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card divide-y">
|
|
||||||
{pageKeys.map((key) => (
|
|
||||||
<div key={key} className="flex items-center gap-3 px-4 py-2">
|
|
||||||
<span className="text-xs font-mono text-muted-foreground w-64 truncate flex-shrink-0">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
value={activeData[key]}
|
id="cms-translation-search"
|
||||||
onChange={(e) => updateValue(key, e.target.value)}
|
type="search"
|
||||||
className="flex-1 h-8 text-sm"
|
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>
|
||||||
))}
|
)}
|
||||||
</div>
|
<div className="divide-y rounded-lg border bg-card">
|
||||||
|
{pageKeys.length === 0 && (
|
||||||
{totalPages > 1 && (
|
<p className="p-6 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
{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">
|
<span className="text-sm text-muted-foreground">
|
||||||
Page {page} of {totalPages}
|
{t("page", {
|
||||||
|
page: currentPage,
|
||||||
|
total: totalPages,
|
||||||
|
count: filteredKeys.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => setPage(page - 1)}
|
onClick={() => setPage(currentPage - 1)}
|
||||||
>
|
>
|
||||||
Prev
|
{t("previous")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={page >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(currentPage + 1)}
|
||||||
>
|
>
|
||||||
Next
|
{t("next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,10 @@ export default async function UserShowPage({
|
|||||||
const t = await getTranslations("pages.admin.users.detail");
|
const t = await getTranslations("pages.admin.users.detail");
|
||||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||||
const data = await loadUserById(id);
|
const data = await loadUserById(id);
|
||||||
const sanctions = await loadUserSanctions(data.user.id);
|
const [sanctions, watched] = await Promise.all([
|
||||||
const watched = await getWatchedUserIds(session.user.id);
|
loadUserSanctions(data.user.id),
|
||||||
|
getWatchedUserIds(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -54,7 +56,7 @@ export default async function UserShowPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||||
<TabsList>
|
<TabsList className="h-auto flex-wrap justify-start">
|
||||||
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
|
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
|
||||||
<TabsTrigger value="badges">
|
<TabsTrigger value="badges">
|
||||||
{t("tabBadges", { count: data.user.badges.length })}
|
{t("tabBadges", { count: data.user.badges.length })}
|
||||||
@@ -75,7 +77,7 @@ export default async function UserShowPage({
|
|||||||
<TabsContent value="badges">
|
<TabsContent value="badges">
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<h3 className="text-sm font-semibold mb-3">
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
Badges ({data.user.badges.length})
|
{t("tabBadges", { count: data.user.badges.length })}
|
||||||
</h3>
|
</h3>
|
||||||
<UserBadgesReadonly badges={data.user.badges} />
|
<UserBadgesReadonly badges={data.user.badges} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq, like, or } from "drizzle-orm";
|
import { eq, like, or } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
|
||||||
import { apiOk } from "@/lib/api";
|
import { apiOk } from "@/lib/api";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
WebsiteRareValues,
|
WebsiteRareValues,
|
||||||
WebsiteShopArticles,
|
WebsiteShopArticles,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export type AdminSearchResult = {
|
export type AdminSearchResult = {
|
||||||
@@ -53,102 +54,130 @@ export const GET = withAdmin(
|
|||||||
return apiOk({ results: [] });
|
return apiOk({ results: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSearch = (href: string) => {
|
||||||
|
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
|
||||||
|
(item) => item.href === href,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
!!item &&
|
||||||
|
navItemIsAllowed(item, {
|
||||||
|
isSuperAdmin: context.permissions.isSuperAdmin,
|
||||||
|
has: (slug) =>
|
||||||
|
canAccess(context.permissions, slug, context.session.user.rank),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const pattern = `%${escapeLike(q)}%`;
|
const pattern = `%${escapeLike(q)}%`;
|
||||||
const idExact = Number.parseInt(q, 10);
|
const idExact = Number.parseInt(q, 10);
|
||||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||||
|
|
||||||
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db
|
canSearch("/admin/users")
|
||||||
.select({
|
? db
|
||||||
id: User.id,
|
.select({
|
||||||
title: User.username,
|
id: User.id,
|
||||||
subtitle: User.mail,
|
title: User.username,
|
||||||
})
|
subtitle: User.mail,
|
||||||
.from(User)
|
})
|
||||||
.where(
|
.from(User)
|
||||||
or(
|
.where(
|
||||||
like(User.username, pattern),
|
or(
|
||||||
like(User.mail, pattern),
|
like(User.username, pattern),
|
||||||
...(hasId ? [eq(User.id, idExact)] : []),
|
like(User.mail, pattern),
|
||||||
),
|
...(hasId ? [eq(User.id, idExact)] : []),
|
||||||
)
|
),
|
||||||
.limit(PER_TYPE),
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
db
|
canSearch("/admin/articles")
|
||||||
.select({
|
? db
|
||||||
id: WebsiteArticles.id,
|
.select({
|
||||||
title: WebsiteArticles.title,
|
id: WebsiteArticles.id,
|
||||||
subtitle: WebsiteArticles.slug,
|
title: WebsiteArticles.title,
|
||||||
})
|
subtitle: WebsiteArticles.slug,
|
||||||
.from(WebsiteArticles)
|
})
|
||||||
.where(
|
.from(WebsiteArticles)
|
||||||
or(
|
.where(
|
||||||
like(WebsiteArticles.title, pattern),
|
or(
|
||||||
like(WebsiteArticles.slug, pattern),
|
like(WebsiteArticles.title, pattern),
|
||||||
),
|
like(WebsiteArticles.slug, pattern),
|
||||||
)
|
),
|
||||||
.limit(PER_TYPE),
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
db
|
canSearch("/admin/rooms")
|
||||||
.select({
|
? db
|
||||||
id: Rooms.id,
|
.select({
|
||||||
title: Rooms.name,
|
id: Rooms.id,
|
||||||
subtitle: Rooms.ownerName,
|
title: Rooms.name,
|
||||||
})
|
subtitle: Rooms.ownerName,
|
||||||
.from(Rooms)
|
})
|
||||||
.where(
|
.from(Rooms)
|
||||||
or(
|
.where(
|
||||||
like(Rooms.name, pattern),
|
or(
|
||||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
like(Rooms.name, pattern),
|
||||||
),
|
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||||
)
|
),
|
||||||
.limit(PER_TYPE),
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
db
|
canSearch("/admin/guilds")
|
||||||
.select({
|
? db
|
||||||
id: Guilds.id,
|
.select({
|
||||||
title: Guilds.name,
|
id: Guilds.id,
|
||||||
subtitle: Guilds.description,
|
title: Guilds.name,
|
||||||
})
|
subtitle: Guilds.description,
|
||||||
.from(Guilds)
|
})
|
||||||
.where(
|
.from(Guilds)
|
||||||
or(
|
.where(
|
||||||
like(Guilds.name, pattern),
|
or(
|
||||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
like(Guilds.name, pattern),
|
||||||
),
|
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||||
)
|
),
|
||||||
.limit(PER_TYPE),
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
db
|
canSearch("/admin/shop")
|
||||||
.select({
|
? db
|
||||||
id: WebsiteShopArticles.id,
|
.select({
|
||||||
title: WebsiteShopArticles.name,
|
id: WebsiteShopArticles.id,
|
||||||
subtitle: WebsiteShopArticles.info,
|
title: WebsiteShopArticles.name,
|
||||||
})
|
subtitle: WebsiteShopArticles.info,
|
||||||
.from(WebsiteShopArticles)
|
})
|
||||||
.where(
|
.from(WebsiteShopArticles)
|
||||||
or(
|
.where(
|
||||||
like(WebsiteShopArticles.name, pattern),
|
or(
|
||||||
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
like(WebsiteShopArticles.name, pattern),
|
||||||
),
|
...(hasId
|
||||||
)
|
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
|
||||||
.limit(PER_TYPE),
|
: []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
|
||||||
db
|
canSearch("/admin/rare-values")
|
||||||
.select({
|
? db
|
||||||
id: WebsiteRareValues.id,
|
.select({
|
||||||
title: WebsiteRareValues.name,
|
id: WebsiteRareValues.id,
|
||||||
subtitle: WebsiteRareValues.itemId,
|
title: WebsiteRareValues.name,
|
||||||
})
|
subtitle: WebsiteRareValues.itemId,
|
||||||
.from(WebsiteRareValues)
|
})
|
||||||
.where(
|
.from(WebsiteRareValues)
|
||||||
or(
|
.where(
|
||||||
like(WebsiteRareValues.name, pattern),
|
or(
|
||||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
like(WebsiteRareValues.name, pattern),
|
||||||
),
|
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||||
)
|
),
|
||||||
.limit(PER_TYPE),
|
)
|
||||||
|
.limit(PER_TYPE)
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const groupMap: Record<
|
const groupMap: Record<
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { and, eq, or, sql } from "drizzle-orm";
|
import { and, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/articles/:slug — single website_article by slug, including the
|
* GET /api/articles/:slug — single website_article by slug, including the
|
||||||
@@ -14,9 +16,9 @@ export async function GET(
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(
|
const data = await cacheNews(
|
||||||
apiCacheKey(`articles:${slug}`),
|
apiCacheKey(`articles:${slug}`),
|
||||||
60,
|
60_000,
|
||||||
async () => {
|
async () => {
|
||||||
const [article] = await db
|
const [article] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -55,7 +57,8 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
// DB unavailable — treat as not found rather than a 500.
|
// DB unavailable — treat as not found rather than a 500.
|
||||||
return apiJson({ error: "Article not found" }, { status: 200 });
|
return apiJson({ error: "Article not found" }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson, pagination } from "@/lib/api";
|
import { apiJson, pagination } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/articles — paginated list of website_articles, newest first.
|
* GET /api/articles — paginated list of website_articles, newest first.
|
||||||
@@ -13,9 +15,9 @@ export async function GET(req: Request) {
|
|||||||
const { page, perPage, skip, take } = pagination(sp);
|
const { page, perPage, skip, take } = pagination(sp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(
|
const data = await cacheNews(
|
||||||
apiCacheKey(`articles:${page}:${perPage}`),
|
apiCacheKey(`articles:${page}:${perPage}`),
|
||||||
60,
|
60_000,
|
||||||
async () => {
|
async () => {
|
||||||
const publishedFilter = and(
|
const publishedFilter = and(
|
||||||
eq(WebsiteArticles.status, "published"),
|
eq(WebsiteArticles.status, "published"),
|
||||||
@@ -60,7 +62,8 @@ export async function GET(req: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
// DB unavailable — return an empty payload instead of a 500.
|
// DB unavailable — return an empty payload instead of a 500.
|
||||||
return apiJson(
|
return apiJson(
|
||||||
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { env } from "@/env";
|
|||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/home — combined landing payload: the latest 4 website_articles and
|
* GET /api/home — combined landing payload: the latest 4 website_articles and
|
||||||
@@ -12,7 +14,7 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
|||||||
*/
|
*/
|
||||||
export async function GET(_req: Request) {
|
export async function GET(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const data = await redisCache(apiCacheKey("home"), 15, async () => {
|
const data = await cacheNews(apiCacheKey("home"), 15_000, async () => {
|
||||||
const [articles, onlineRows, hotelName] = await Promise.all([
|
const [articles, onlineRows, hotelName] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -45,7 +47,8 @@ export async function GET(_req: Request) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return apiJson(data);
|
return apiJson(data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
return apiJson(
|
return apiJson(
|
||||||
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
|
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -26,6 +27,7 @@ export function AdminMobileWrapper({
|
|||||||
sidebar: ReactNode;
|
sidebar: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.nav");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const close = useCallback(() => setOpen(false), []);
|
const close = useCallback(() => setOpen(false), []);
|
||||||
const mobile = useMemo(() => ({ close }), [close]);
|
const mobile = useMemo(() => ({ close }), [close]);
|
||||||
@@ -57,6 +59,9 @@ export function AdminMobileWrapper({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-label={t(open ? "closeSidebar" : "openSidebar")}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="admin-sidebar-panel"
|
||||||
className="flex items-center justify-center w-9 h-9 rounded-lg
|
className="flex items-center justify-center w-9 h-9 rounded-lg
|
||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
|
||||||
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
||||||
@@ -67,7 +72,7 @@ export function AdminMobileWrapper({
|
|||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
||||||
>
|
>
|
||||||
Admin Panel
|
{t("housekeeping")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +80,7 @@ export function AdminMobileWrapper({
|
|||||||
{open && (
|
{open && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close menu"
|
aria-label={t("closeSidebar")}
|
||||||
className="fixed inset-0 z-40 lg:hidden"
|
className="fixed inset-0 z-40 lg:hidden"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
@@ -84,6 +89,7 @@ export function AdminMobileWrapper({
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<div
|
||||||
|
id="admin-sidebar-panel"
|
||||||
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
|
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
|
||||||
open ? "translate-x-0" : "-translate-x-full"
|
open ? "translate-x-0" : "-translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -11,15 +11,9 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||||
import {
|
import type { AdminNavItem } from "@/lib/admin-nav";
|
||||||
ADMIN_NAV_GROUPS,
|
import type { AdminNavConfig } from "@/lib/admin-nav-config";
|
||||||
type AdminNavItem,
|
import { visibleAdminNavigation } from "@/lib/admin-navigation";
|
||||||
navItemIsAllowed,
|
|
||||||
} from "@/lib/admin-nav";
|
|
||||||
import {
|
|
||||||
type AdminNavConfig,
|
|
||||||
applyAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STORAGE_KEY = "admin-nav-collapsed";
|
const STORAGE_KEY = "admin-nav-collapsed";
|
||||||
@@ -83,24 +77,12 @@ export function AdminSidebarNav({
|
|||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const allowed = useMemo(
|
const groups = useMemo(
|
||||||
() => new Set(allowedPermissions),
|
() =>
|
||||||
[allowedPermissions],
|
visibleAdminNavigation({ allowedPermissions, isSuperAdmin, navConfig }),
|
||||||
|
[allowedPermissions, isSuperAdmin, navConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const access = {
|
|
||||||
isSuperAdmin,
|
|
||||||
has: (slug: string) => allowed.has(slug),
|
|
||||||
};
|
|
||||||
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
|
|
||||||
.map((group) => ({
|
|
||||||
...group,
|
|
||||||
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
|
||||||
}))
|
|
||||||
.filter((group) => group.items.length > 0);
|
|
||||||
}, [allowed, isSuperAdmin, navConfig]);
|
|
||||||
|
|
||||||
const search = query.trim().toLocaleLowerCase();
|
const search = query.trim().toLocaleLowerCase();
|
||||||
const visibleGroups = useMemo(
|
const visibleGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { SearchDialog } from "@/components/admin/search-dialog";
|
|||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { findAdminHub } from "@/lib/admin-nav";
|
import { findAdminHub } from "@/lib/admin-nav";
|
||||||
|
import type { AdminNavigationOptions } from "@/lib/admin-navigation";
|
||||||
|
|
||||||
export function AdminTopbar({
|
export function AdminTopbar({
|
||||||
staff,
|
staff,
|
||||||
|
navigation,
|
||||||
}: {
|
}: {
|
||||||
staff: { id: number; username: string; rank: number };
|
staff: { id: number; username: string; rank: number };
|
||||||
|
navigation: AdminNavigationOptions;
|
||||||
}) {
|
}) {
|
||||||
const tNav = useTranslations("pages.admin.nav");
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
const tHubs = useTranslations("pages.admin.hubs");
|
const tHubs = useTranslations("pages.admin.hubs");
|
||||||
@@ -19,7 +22,7 @@ export function AdminTopbar({
|
|||||||
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
|
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
|
<header className="flex items-center justify-between flex-wrap gap-3 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
|
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
|
||||||
{title}
|
{title}
|
||||||
@@ -30,7 +33,7 @@ export function AdminTopbar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SearchDialog />
|
<SearchDialog navigation={navigation} />
|
||||||
<div className="flex items-center gap-1.5 lg:hidden">
|
<div className="flex items-center gap-1.5 lg:hidden">
|
||||||
<LanguageSwitcher variant="admin" />
|
<LanguageSwitcher variant="admin" />
|
||||||
<ThemeSwitcher variant="admin" />
|
<ThemeSwitcher variant="admin" />
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { count, eq, ne } from "drizzle-orm";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Link from "@/components/link";
|
||||||
|
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||||
|
import { ErrorStore } from "@/lib/error-monitor";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||||
|
|
||||||
|
export async function AdminWorkQueue() {
|
||||||
|
const [{ session, permissions }, t] = await Promise.all([
|
||||||
|
getAdminContext(),
|
||||||
|
getTranslations("pages.admin.workQueue"),
|
||||||
|
]);
|
||||||
|
const allowed = (permission: string) =>
|
||||||
|
canAccess(permissions, permission, session.user.rank);
|
||||||
|
const entries: { key: string; href: string; load: () => Promise<number> }[] =
|
||||||
|
[];
|
||||||
|
if (allowed(PERMS.TICKETS_VIEW))
|
||||||
|
entries.push({
|
||||||
|
key: "tickets",
|
||||||
|
href: "/admin/tickets",
|
||||||
|
load: async () => {
|
||||||
|
const [cms, help] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(WebsiteTicket)
|
||||||
|
.where(ne(WebsiteTicket.status, "closed")),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.open, true)),
|
||||||
|
]);
|
||||||
|
return (cms[0]?.total ?? 0) + (help[0]?.total ?? 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (allowed(PERMS.ASSETS_IMPORT))
|
||||||
|
entries.push({
|
||||||
|
key: "imports",
|
||||||
|
href: "/admin/studio/furni",
|
||||||
|
load: async () =>
|
||||||
|
(await new ImportJobStore().list()).filter(
|
||||||
|
(job) =>
|
||||||
|
job.userId === Number(session.user.id) &&
|
||||||
|
(job.state === "interrupted" ||
|
||||||
|
job.items.some((item) => item.state === "failed")),
|
||||||
|
).length,
|
||||||
|
});
|
||||||
|
if (allowed(PERMS.DEVOPS_VIEW))
|
||||||
|
entries.push({
|
||||||
|
key: "errors",
|
||||||
|
href: "/admin/devops/cms-errors?status=open",
|
||||||
|
load: async () =>
|
||||||
|
new Set(
|
||||||
|
(await new ErrorStore().read()).records
|
||||||
|
.filter((record) => !record.resolved)
|
||||||
|
.map((record) => record.fingerprint),
|
||||||
|
).size,
|
||||||
|
});
|
||||||
|
const cards = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
return { ...entry, count: await entry.load() };
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.work_queue_failed", error, { queue: entry.key });
|
||||||
|
return { ...entry, count: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!cards.length) return null;
|
||||||
|
return (
|
||||||
|
<section className="mb-6" aria-labelledby="work-queue">
|
||||||
|
<h2
|
||||||
|
id="work-queue"
|
||||||
|
className="mb-3 text-sm font-bold text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
href={card.href}
|
||||||
|
key={card.key}
|
||||||
|
className="admin-card flex items-center gap-4 no-underline focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||||
|
>
|
||||||
|
<span className="text-2xl font-bold tabular-nums text-[var(--admin-text)]">
|
||||||
|
{card.count ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<strong className="block text-sm text-[var(--admin-text)]">
|
||||||
|
{t(card.key)}
|
||||||
|
</strong>
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{card.count === null ? t("unavailable") : t(`${card.key}Hint`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={16} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||||
|
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||||
import { MediaPicker } from "./media-picker";
|
import { MediaPicker } from "./media-picker";
|
||||||
@@ -12,7 +14,7 @@ export function ArticleForm({
|
|||||||
action,
|
action,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => Promise<void>;
|
action: (formData: FormData) => Promise<ArticleSaveResult | void>;
|
||||||
edit?: boolean;
|
edit?: boolean;
|
||||||
defaultValues?: {
|
defaultValues?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -25,12 +27,32 @@ export function ArticleForm({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.articles.form");
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
|
const tAction = useTranslations("pages.admin.actions");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const saving = useRef(false);
|
||||||
|
const editVersion = useRef(0);
|
||||||
|
function markDirty() {
|
||||||
|
editVersion.current++;
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
useUnsavedChanges(dirty, tAction("leaveUnsaved"));
|
||||||
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
||||||
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
|
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
|
||||||
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
||||||
const [image, setImage] = useState(defaultValues?.image ?? "");
|
const [image, setImage] = useState(defaultValues?.image ?? "");
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||||
|
const [publishAt, setPublishAt] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultValues?.publishAt) return;
|
||||||
|
const date = new Date(defaultValues.publishAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return;
|
||||||
|
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||||
|
setPublishAt(local.toISOString().slice(0, 16));
|
||||||
|
}, [defaultValues?.publishAt]);
|
||||||
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||||
@@ -39,175 +61,228 @@ export function ArticleForm({
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
action={action}
|
onChangeCapture={markDirty}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (saving.current) return;
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
if (status === "scheduled" && publishAt) {
|
||||||
|
const date = new Date(publishAt);
|
||||||
|
if (!Number.isNaN(date.getTime()))
|
||||||
|
data.set("publishAt", date.toISOString());
|
||||||
|
}
|
||||||
|
const submittedVersion = editVersion.current;
|
||||||
|
saving.current = true;
|
||||||
|
setSaveError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await action(data);
|
||||||
|
if (result && !result.ok) {
|
||||||
|
setSaveError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editVersion.current === submittedVersion) setDirty(false);
|
||||||
|
if (result?.ok && result.data?.redirectTo)
|
||||||
|
router.push(result.data.redirectTo);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"digest" in error &&
|
||||||
|
String(error.digest).startsWith("NEXT_REDIRECT")
|
||||||
|
) {
|
||||||
|
setDirty(false);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
setSaveError(tAction("saveError"));
|
||||||
|
} finally {
|
||||||
|
saving.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
style={{ display: "grid", gap: "1rem" }}
|
style={{ display: "grid", gap: "1rem" }}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<fieldset disabled={pending} className="contents">
|
||||||
<label className="block">
|
{saveError && (
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
<p role="alert" className="text-sm">
|
||||||
{t("title")}
|
{saveError}
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={t("titlePlaceholder")}
|
|
||||||
required
|
|
||||||
className="input input-bordered mt-1 w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
||||||
{t("slug")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
name="slug"
|
|
||||||
value={slugTouched ? slug : suggestedSlug}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSlug(e.target.value);
|
|
||||||
setSlugTouched(true);
|
|
||||||
}}
|
|
||||||
placeholder={t("slugPlaceholder")}
|
|
||||||
className="input input-bordered mt-1 w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="block">
|
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
||||||
{t("image")}
|
|
||||||
</span>
|
|
||||||
<div className="mt-1 flex gap-2 items-start">
|
|
||||||
<input
|
|
||||||
aria-label={t("image")}
|
|
||||||
name="image"
|
|
||||||
value={image}
|
|
||||||
onChange={(e) => {
|
|
||||||
setImage(e.target.value);
|
|
||||||
setImageError(false);
|
|
||||||
}}
|
|
||||||
placeholder={t("imagePlaceholder")}
|
|
||||||
className="input input-bordered min-w-0 flex-1"
|
|
||||||
/>
|
|
||||||
<MediaPicker
|
|
||||||
onSelect={(url) => {
|
|
||||||
setImage(url);
|
|
||||||
setImageError(false);
|
|
||||||
}}
|
|
||||||
current={image}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{image && !imageError ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={image}
|
|
||||||
alt="preview"
|
|
||||||
onError={() => setImageError(true)}
|
|
||||||
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
|
||||||
style={{ maxHeight: 160 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{imageError ? (
|
|
||||||
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
|
||||||
Image not found — enter a valid URL or browse.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
||||||
{t("body")}
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
name="shortStory"
|
|
||||||
defaultValue={defaultValues?.shortStory ?? ""}
|
|
||||||
placeholder={t("bodyPlaceholder")}
|
|
||||||
rows={2}
|
|
||||||
className="input input-bordered mt-1 w-full resize-y"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="block">
|
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
||||||
{t("body")}
|
|
||||||
</span>
|
|
||||||
<RichText
|
|
||||||
name="fullStory"
|
|
||||||
defaultValue={defaultValues?.fullStory ?? ""}
|
|
||||||
placeholder={t("bodyPlaceholder")}
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
|
||||||
{t("status")}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
name="status"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
className="input input-bordered mt-1 w-full"
|
|
||||||
>
|
|
||||||
<option value="draft">{t("statusDraft")}</option>
|
|
||||||
<option value="scheduled">{t("statusScheduled")}</option>
|
|
||||||
<option value="published">{t("statusPublished")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{status === "scheduled" ? (
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
{t("publishAt")}
|
{t("title")}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
name="title"
|
||||||
name="publishAt"
|
maxLength={255}
|
||||||
defaultValue={defaultValues?.publishAt ?? ""}
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("titlePlaceholder")}
|
||||||
required
|
required
|
||||||
className="input input-bordered mt-1 w-full"
|
className="input input-bordered mt-1 w-full"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
<label className="block">
|
||||||
<button
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
type="button"
|
{t("slug")}
|
||||||
className="btn btn-outline"
|
</span>
|
||||||
onClick={() => {
|
<input
|
||||||
if (!formRef.current) return;
|
name="slug"
|
||||||
const values = new FormData(formRef.current);
|
maxLength={255}
|
||||||
setPreview({
|
value={slugTouched ? slug : suggestedSlug}
|
||||||
title: String(values.get("title") ?? ""),
|
onChange={(e) => {
|
||||||
image: String(values.get("image") ?? ""),
|
setSlug(e.target.value);
|
||||||
summary: String(values.get("shortStory") ?? ""),
|
setSlugTouched(true);
|
||||||
body: String(values.get("fullStory") ?? ""),
|
}}
|
||||||
});
|
placeholder={t("slugPlaceholder")}
|
||||||
}}
|
className="input input-bordered mt-1 w-full"
|
||||||
>
|
/>
|
||||||
Preview
|
</label>
|
||||||
</button>
|
</div>
|
||||||
<ArticleSaveButton label={t("save")} />
|
|
||||||
</div>
|
<div className="block">
|
||||||
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("image")}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 flex gap-2 items-start">
|
||||||
|
<input
|
||||||
|
aria-label={t("image")}
|
||||||
|
name="image"
|
||||||
|
maxLength={255}
|
||||||
|
value={image}
|
||||||
|
onChange={(e) => {
|
||||||
|
setImage(e.target.value);
|
||||||
|
setImageError(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("imagePlaceholder")}
|
||||||
|
className="input input-bordered min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
<MediaPicker
|
||||||
|
onSelect={(url) => {
|
||||||
|
setImage(url);
|
||||||
|
markDirty();
|
||||||
|
setImageError(false);
|
||||||
|
}}
|
||||||
|
current={image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{image && !imageError ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={t("imagePreview")}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
||||||
|
style={{ maxHeight: 160 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{imageError ? (
|
||||||
|
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
||||||
|
{t("imageNotFound")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("summary")}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
name="shortStory"
|
||||||
|
maxLength={255}
|
||||||
|
defaultValue={defaultValues?.shortStory ?? ""}
|
||||||
|
placeholder={t("bodyPlaceholder")}
|
||||||
|
rows={2}
|
||||||
|
className="input input-bordered mt-1 w-full resize-y"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("body")}
|
||||||
|
</span>
|
||||||
|
<RichText
|
||||||
|
onChange={markDirty}
|
||||||
|
name="fullStory"
|
||||||
|
defaultValue={defaultValues?.fullStory ?? ""}
|
||||||
|
placeholder={t("bodyPlaceholder")}
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("status")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="input input-bordered mt-1 w-full"
|
||||||
|
>
|
||||||
|
<option value="draft">{t("statusDraft")}</option>
|
||||||
|
<option value="scheduled">{t("statusScheduled")}</option>
|
||||||
|
<option value="published">{t("statusPublished")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{status === "scheduled" ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("publishAt")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="publishAt"
|
||||||
|
value={publishAt}
|
||||||
|
onChange={(e) => setPublishAt(e.target.value)}
|
||||||
|
required
|
||||||
|
className="input input-bordered mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (!formRef.current) return;
|
||||||
|
const values = new FormData(formRef.current);
|
||||||
|
setPreview({
|
||||||
|
title: String(values.get("title") ?? ""),
|
||||||
|
image: String(values.get("image") ?? ""),
|
||||||
|
summary: String(values.get("shortStory") ?? ""),
|
||||||
|
body: String(values.get("fullStory") ?? ""),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tAction("preview")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={pending}
|
||||||
|
aria-busy={pending}
|
||||||
|
>
|
||||||
|
{pending ? tAction("saving") : t("save")}
|
||||||
|
</button>
|
||||||
|
{dirty && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
className="self-center text-xs text-[var(--admin-text-muted)]"
|
||||||
|
>
|
||||||
|
{tAction("unsaved")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArticleSaveButton({ label }: { label: string }) {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={pending}
|
|
||||||
aria-busy={pending}
|
|
||||||
>
|
|
||||||
{pending ? "Saving…" : label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,6 +29,7 @@ export function ArticlePreview({
|
|||||||
data: ArticlePreviewData | null;
|
data: ArticlePreviewData | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
const document = data
|
const document = data
|
||||||
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
||||||
: "";
|
: "";
|
||||||
@@ -40,13 +42,13 @@ export function ArticlePreview({
|
|||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Article preview</DialogTitle>
|
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Content preview. The published page applies the site theme.
|
{t("previewDescription")}
|
||||||
</p>
|
</p>
|
||||||
<iframe
|
<iframe
|
||||||
title="Article content preview"
|
title={t("previewFrame")}
|
||||||
sandbox=""
|
sandbox=""
|
||||||
srcDoc={document}
|
srcDoc={document}
|
||||||
className="min-h-0 flex-1 w-full bg-background rounded border"
|
className="min-h-0 flex-1 w-full bg-background rounded border"
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -38,8 +40,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
||||||
|
import { tablePageNumbers } from "@/lib/admin-table-state";
|
||||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,29 +70,47 @@ interface DataTableProps<T extends { id: number | string }> {
|
|||||||
export function DataTable<T extends { id: number | string }>({
|
export function DataTable<T extends { id: number | string }>({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder,
|
||||||
bulkActions,
|
bulkActions,
|
||||||
onBulkAction,
|
onBulkAction,
|
||||||
exportUrl,
|
exportUrl,
|
||||||
presets,
|
presets,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
|
const t = useTranslations("pages.admin.table");
|
||||||
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(
|
|
||||||
new Set(),
|
const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`;
|
||||||
);
|
const [selection, setSelection] = useState<{
|
||||||
const [searchValue, setSearchValue] = useState(
|
scope: string;
|
||||||
searchParams.get("search") || "",
|
ids: Set<number | string>;
|
||||||
);
|
}>({ scope, ids: new Set() });
|
||||||
|
const selectedIds =
|
||||||
|
selection.scope === scope ? selection.ids : new Set<number | string>();
|
||||||
|
function setSelectedIds(
|
||||||
|
value:
|
||||||
|
| Set<number | string>
|
||||||
|
| ((prev: Set<number | string>) => Set<number | string>),
|
||||||
|
) {
|
||||||
|
setSelection({
|
||||||
|
scope,
|
||||||
|
ids: typeof value === "function" ? value(selectedIds) : value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const urlSearch = searchParams.get("search") ?? "";
|
||||||
|
const [draft, setDraft] = useState({ base: urlSearch, value: urlSearch });
|
||||||
|
if (draft.base !== urlSearch) setDraft({ base: urlSearch, value: urlSearch });
|
||||||
|
const searchValue = draft.base === urlSearch ? draft.value : urlSearch;
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const bulkLock = useRef(false);
|
||||||
|
|
||||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const currentSort = searchParams.get("sort") || "";
|
const currentSort = searchParams.get("sort") || "";
|
||||||
const currentOrder = searchParams.get("order") || "desc";
|
const currentOrder = searchParams.get("order") || "desc";
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(searchValue, 300);
|
|
||||||
const isFirstRender = useRef(true);
|
|
||||||
|
|
||||||
// Collect active filters from URL
|
// Collect active filters from URL
|
||||||
const filterColumns = columns.filter(
|
const filterColumns = columns.filter(
|
||||||
(col) => col.filterOptions && col.filterOptions.length > 0,
|
(col) => col.filterOptions && col.filterOptions.length > 0,
|
||||||
@@ -110,27 +130,31 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
else params.delete(key);
|
else params.delete(key);
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`?${params.toString()}`);
|
router.push(`?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstRender.current) {
|
if (searchValue === urlSearch) return;
|
||||||
isFirstRender.current = false;
|
const timer = setTimeout(() => {
|
||||||
return;
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
}
|
if (searchValue) params.set("search", searchValue);
|
||||||
updateParams({ search: debouncedSearch, page: "1" });
|
else params.delete("search");
|
||||||
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
params.set("page", "1");
|
||||||
|
startTransition(() =>
|
||||||
|
router.replace(`?${params.toString()}`, { scroll: false }),
|
||||||
|
);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchValue, urlSearch, searchParams, router]);
|
||||||
function handleSearch(value: string) {
|
function handleSearch(value: string) {
|
||||||
setSearchValue(value);
|
setDraft({ base: urlSearch, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSort(key: string) {
|
function handleSort(key: string) {
|
||||||
const newOrder =
|
const newOrder =
|
||||||
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
|
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
|
||||||
updateParams({ sort: key, order: newOrder });
|
updateParams({ sort: key, order: newOrder, page: "1" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
@@ -173,10 +197,33 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleBulkAction(action: string) {
|
async function handleBulkAction(action: string) {
|
||||||
if (selectedIds.size === 0 || !onBulkAction) return;
|
if (
|
||||||
await onBulkAction(action, Array.from(selectedIds));
|
selectedIds.size === 0 ||
|
||||||
setSelectedIds(new Set());
|
!onBulkAction ||
|
||||||
router.refresh();
|
bulkLock.current ||
|
||||||
|
isPending
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const definition = bulkActions?.find((item) => item.value === action);
|
||||||
|
if (
|
||||||
|
definition?.variant === "destructive" &&
|
||||||
|
!window.confirm(
|
||||||
|
t("confirmBulk", { action: definition.label, count: selectedIds.size }),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
bulkLock.current = true;
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
await onBulkAction(action, Array.from(selectedIds));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("bulkError"));
|
||||||
|
} finally {
|
||||||
|
bulkLock.current = false;
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSortIcon(key: string) {
|
function getSortIcon(key: string) {
|
||||||
@@ -206,7 +253,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 min-w-0" aria-busy={isPending || bulkBusy}>
|
||||||
{/* Preset filter chips */}
|
{/* Preset filter chips */}
|
||||||
{presets && presets.length > 0 && (
|
{presets && presets.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -236,9 +283,11 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder ?? t("search")}
|
||||||
|
aria-label={searchPlaceholder ?? t("search")}
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
style={{ paddingLeft: 36 }}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +298,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
Filters
|
{t("filters")}
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -263,7 +312,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<PopoverContent className="w-72" align="start">
|
<PopoverContent className="w-72" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-medium">Filters</h4>
|
<h4 className="text-sm font-medium">{t("filters")}</h4>
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -271,7 +320,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
>
|
>
|
||||||
Clear all
|
{t("clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +340,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">All</SelectItem>
|
<SelectItem value="__all__">{t("all")}</SelectItem>
|
||||||
{col.filterOptions?.map((opt) => (
|
{col.filterOptions?.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -320,6 +369,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
{col.label}: {opt?.label || val}
|
{col.label}: {opt?.label || val}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={t("removeFilter", { name: col.label })}
|
||||||
onClick={() => handleFilterChange(col, "__all__")}
|
onClick={() => handleFilterChange(col, "__all__")}
|
||||||
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
|
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
|
||||||
>
|
>
|
||||||
@@ -336,12 +386,12 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
Columns
|
{t("columns")}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-48" align="start">
|
<PopoverContent className="w-48" align="start">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
|
<h4 className="text-sm font-medium mb-2">{t("columns")}</h4>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -349,6 +399,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!hiddenColumns.has(col.key)}
|
checked={!hiddenColumns.has(col.key)}
|
||||||
|
aria-label={col.label}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
setHiddenColumns((prev) => {
|
setHiddenColumns((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -375,7 +426,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{PER_PAGE_OPTIONS.map((n) => (
|
{PER_PAGE_OPTIONS.map((n) => (
|
||||||
<SelectItem key={n} value={String(n)}>
|
<SelectItem key={n} value={String(n)}>
|
||||||
{n} / page
|
{t("perPage", { count: n })}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -393,13 +444,14 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
{bulkActions && selectedIds.size > 0 && (
|
{bulkActions && selectedIds.size > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{selectedIds.size} selected
|
{t("selected", { count: selectedIds.size })}
|
||||||
</span>
|
</span>
|
||||||
{bulkActions.map((action) => (
|
{bulkActions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.value}
|
key={action.value}
|
||||||
variant={action.variant || "default"}
|
variant={action.variant || "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={bulkBusy || isPending}
|
||||||
onClick={() => handleBulkAction(action.value)}
|
onClick={() => handleBulkAction(action.value)}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
@@ -409,7 +461,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ml-auto text-sm text-muted-foreground">
|
<div className="ml-auto text-sm text-muted-foreground">
|
||||||
{data.total} results
|
{t("results", { count: data.total })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,6 +477,8 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
selectedIds.size === data.rows.length &&
|
selectedIds.size === data.rows.length &&
|
||||||
data.rows.length > 0
|
data.rows.length > 0
|
||||||
}
|
}
|
||||||
|
aria-label={t("selectAll")}
|
||||||
|
disabled={isPending || bulkBusy}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -459,7 +513,7 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
}
|
}
|
||||||
className="h-24 text-center text-muted-foreground"
|
className="h-24 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No results found.
|
{t("empty")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -473,6 +527,8 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(row.id)}
|
checked={selectedIds.has(row.id)}
|
||||||
|
aria-label={t("selectRow", { id: String(row.id) })}
|
||||||
|
disabled={isPending || bulkBusy}
|
||||||
onCheckedChange={() => toggleSelect(row.id)}
|
onCheckedChange={() => toggleSelect(row.id)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -504,31 +560,25 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
{data.lastPage > 1 && (
|
{data.lastPage > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {data.page} of {data.lastPage}
|
{t("pageOf", { page: data.page, last: data.lastPage })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1 flex-wrap justify-end">
|
<div className="flex items-center gap-1 flex-wrap justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={data.page <= 1}
|
aria-label={t("previous")}
|
||||||
|
disabled={data.page <= 1 || isPending}
|
||||||
onClick={() => handlePageChange(data.page - 1)}
|
onClick={() => handlePageChange(data.page - 1)}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => {
|
{tablePageNumbers(data.page, data.lastPage).map((pageNum) => {
|
||||||
let pageNum: number;
|
|
||||||
if (data.lastPage <= 7) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (data.page <= 4) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (data.page >= data.lastPage - 3) {
|
|
||||||
pageNum = data.lastPage - 6 + i;
|
|
||||||
} else {
|
|
||||||
pageNum = data.page - 3 + i;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
|
aria-label={t("page", { page: pageNum })}
|
||||||
|
aria-current={pageNum === data.page ? "page" : undefined}
|
||||||
|
disabled={isPending}
|
||||||
variant={pageNum === data.page ? "default" : "outline"}
|
variant={pageNum === data.page ? "default" : "outline"}
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
@@ -540,7 +590,8 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={data.page >= data.lastPage}
|
aria-label={t("next")}
|
||||||
|
disabled={data.page >= data.lastPage || isPending}
|
||||||
onClick={() => handlePageChange(data.page + 1)}
|
onClick={() => handlePageChange(data.page + 1)}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
|||||||
@@ -57,13 +57,17 @@ export function RichText({
|
|||||||
defaultValue = "",
|
defaultValue = "",
|
||||||
placeholder,
|
placeholder,
|
||||||
rows = 10,
|
rows = 10,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
onChange?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const changeCallback = useRef(onChange);
|
||||||
|
changeCallback.current = onChange;
|
||||||
const reactId = useId();
|
const reactId = useId();
|
||||||
// Stable, querySelector-safe id (useId() contains ':').
|
// Stable, querySelector-safe id (useId() contains ':').
|
||||||
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||||
@@ -94,11 +98,16 @@ export function RichText({
|
|||||||
on: (ev: string, cb: () => void) => void;
|
on: (ev: string, cb: () => void) => void;
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
}) => {
|
}) => {
|
||||||
const sync = () => {
|
const sync = (notify: boolean) => {
|
||||||
if (textareaRef.current)
|
const field = textareaRef.current;
|
||||||
textareaRef.current.value = editor.getContent();
|
if (!field) return;
|
||||||
|
const html = editor.getContent();
|
||||||
|
const changed = field.value !== html;
|
||||||
|
field.value = html;
|
||||||
|
if (notify && changed) changeCallback.current?.();
|
||||||
};
|
};
|
||||||
editor.on("change keyup undo redo SetContent", sync);
|
editor.on("SetContent", () => sync(false));
|
||||||
|
editor.on("change keyup undo redo", () => sync(true));
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import {
|
||||||
|
type AdminNavigationOptions,
|
||||||
|
visibleAdminNavigation,
|
||||||
|
} from "@/lib/admin-navigation";
|
||||||
|
|
||||||
type SearchResult = AdminSearchResult;
|
type SearchResult = AdminSearchResult;
|
||||||
|
|
||||||
@@ -23,13 +32,30 @@ type SearchResponse = {
|
|||||||
results: SearchResult[];
|
results: SearchResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SearchDialog() {
|
export function SearchDialog({
|
||||||
|
navigation,
|
||||||
|
}: {
|
||||||
|
navigation: AdminNavigationOptions;
|
||||||
|
}) {
|
||||||
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
|
const getPages = (queryPlaceholder: string) =>
|
||||||
|
visibleAdminNavigation(navigation)
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.items.map((item) => ({ ...item, group: tNav(group.labelKey) })),
|
||||||
|
)
|
||||||
|
.filter((item) =>
|
||||||
|
[tNav(item.labelKey), item.group, item.href]
|
||||||
|
.join(" ")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(queryPlaceholder),
|
||||||
|
);
|
||||||
const t = useTranslations("pages.admin.search");
|
const t = useTranslations("pages.admin.search");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
const debouncedQuery = useDebounce(query, 250);
|
const debouncedQuery = useDebounce(query, 250);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -44,6 +70,8 @@ export function SearchDialog() {
|
|||||||
|
|
||||||
const fetchResults = useCallback(async (q: string) => {
|
const fetchResults = useCallback(async (q: string) => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
setFailed(false);
|
||||||
|
setResults([]);
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -56,21 +84,27 @@ export function SearchDialog() {
|
|||||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) throw new Error("Search request failed");
|
||||||
const data: SearchResponse = await res.json();
|
const data: SearchResponse = await res.json();
|
||||||
if (abortRef.current !== controller) return;
|
if (abortRef.current !== controller || controller.signal.aborted) return;
|
||||||
if (data.ok) setResults(data.results);
|
if (data.ok) setResults(data.results);
|
||||||
|
else setFailed(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
if (abortRef.current === controller) setResults([]);
|
if (abortRef.current === controller) {
|
||||||
|
setResults([]);
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (abortRef.current === controller) setLoading(false);
|
if (abortRef.current === controller) setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
fetchResults(debouncedQuery);
|
fetchResults(debouncedQuery);
|
||||||
}, [debouncedQuery, fetchResults]);
|
return () => abortRef.current?.abort();
|
||||||
|
}, [debouncedQuery, fetchResults, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => abortRef.current?.abort();
|
return () => abortRef.current?.abort();
|
||||||
@@ -94,13 +128,15 @@ export function SearchDialog() {
|
|||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = groupResults(results);
|
const grouped = groupResults(query === debouncedQuery ? results : []);
|
||||||
|
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={t("placeholder")}
|
||||||
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<SearchIcon size={14} />
|
<SearchIcon size={14} />
|
||||||
@@ -110,6 +146,10 @@ export function SearchDialog() {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||||
|
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("description")}
|
||||||
|
</DialogDescription>
|
||||||
<Command
|
<Command
|
||||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
@@ -126,7 +166,31 @@ export function SearchDialog() {
|
|||||||
…
|
…
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
{!loading && !failed && (
|
||||||
|
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{failed && (
|
||||||
|
<p role="alert" className="p-4 text-sm">
|
||||||
|
{t("loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pages.length > 0 && (
|
||||||
|
<CommandGroup heading={t("pages")}>
|
||||||
|
{pages.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.href}
|
||||||
|
value={item.href}
|
||||||
|
onSelect={() => handleSelect(item.href)}
|
||||||
|
>
|
||||||
|
<item.icon size={16} />
|
||||||
|
<span>{tNav(item.labelKey)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{item.group}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
{Object.entries(grouped).map(([group, items]) => (
|
{Object.entries(grouped).map(([group, items]) => (
|
||||||
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
|
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
"use client";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type SearchInputProps = {
|
type SearchInputProps = {
|
||||||
@@ -15,21 +17,24 @@ export function SearchInput({
|
|||||||
placeholder,
|
placeholder,
|
||||||
onClear,
|
onClear,
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
|
const t = useTranslations("pages.admin.table");
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
aria-label={placeholder ?? t("search")}
|
||||||
|
placeholder={placeholder ?? t("search")}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="pl-9 h-9"
|
style={{ paddingLeft: 36, paddingRight: 40 }}
|
||||||
|
className="pl-9 pr-10 h-9"
|
||||||
/>
|
/>
|
||||||
{value && onClear && (
|
{value && onClear && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
aria-label="Clear search"
|
aria-label={t("clearSearch")}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useFormatter, useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||||
export function FurnitureJobHistory({
|
export function FurnitureJobHistory({
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
state: ReturnType<typeof useFurnitureJobs>;
|
state: ReturnType<typeof useFurnitureJobs>;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.importHistory");
|
||||||
|
const format = useFormatter();
|
||||||
return (
|
return (
|
||||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||||
<summary className="cursor-pointer">
|
<summary className="cursor-pointer">
|
||||||
Import history ·{" "}
|
{t("summary", {
|
||||||
{
|
count: state.jobs.filter(
|
||||||
state.jobs.filter(
|
(job) => job.state === "queued" || job.state === "running",
|
||||||
(j) => j.state === "queued" || j.state === "running",
|
).length,
|
||||||
).length
|
})}
|
||||||
}{" "}
|
|
||||||
active
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||||
{state.error && <p role="alert">{state.error}</p>}
|
{state.error && <p role="alert">{state.error}</p>}
|
||||||
{!state.jobs.length && <p>No background imports yet.</p>}
|
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||||
{state.jobs.map((job) => {
|
{state.jobs.map((job) => {
|
||||||
|
const retryItems = retryableJobItems(job);
|
||||||
const done = job.items.filter((i) => i.state === "done"),
|
const done = job.items.filter((i) => i.state === "done"),
|
||||||
failed = job.items.filter((i) => i.state === "failed");
|
failed = job.items.filter((i) => i.state === "failed");
|
||||||
return (
|
return (
|
||||||
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
|
|||||||
className="rounded border border-[var(--admin-border)] p-2"
|
className="rounded border border-[var(--admin-border)] p-2"
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer">
|
<summary className="cursor-pointer">
|
||||||
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
|
{format.dateTime(new Date(job.createdAt), {
|
||||||
{done.length}/{job.items.length} imported · {failed.length}{" "}
|
dateStyle: "short",
|
||||||
failed
|
timeStyle: "short",
|
||||||
|
})}{" "}
|
||||||
|
· {t(job.state)} ·{" "}
|
||||||
|
{t("result", {
|
||||||
|
done: done.length,
|
||||||
|
total: job.items.length,
|
||||||
|
failed: failed.length,
|
||||||
|
})}
|
||||||
</summary>
|
</summary>
|
||||||
|
<progress
|
||||||
|
className="mt-2 h-2 w-full"
|
||||||
|
value={done.length + failed.length}
|
||||||
|
max={Math.max(1, job.items.length)}
|
||||||
|
aria-label={t("progress")}
|
||||||
|
/>
|
||||||
<ul className="space-y-1 py-2">
|
<ul className="space-y-1 py-2">
|
||||||
{job.items.map((item) => (
|
{job.items.map((item) => (
|
||||||
<li key={item.classname}>
|
<li key={item.classname}>
|
||||||
<strong>{item.classname}</strong> · {item.state}
|
<strong>{item.classname}</strong> · {t(item.state)}
|
||||||
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
||||||
{item.error && (
|
{item.error && (
|
||||||
<p className="text-[var(--admin-warning)]">
|
<p className="text-[var(--admin-warning)]">
|
||||||
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{failed.length > 0 && job.state !== "running" && (
|
{retryItems.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={state.busy}
|
disabled={state.busy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void state.submit(failed, {
|
void state.submit(retryItems, {
|
||||||
sourceId: job.sourceId,
|
sourceId: job.sourceId,
|
||||||
mode: job.mode,
|
mode: job.mode,
|
||||||
translate: job.translate,
|
translate: job.translate,
|
||||||
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Retry failed items only
|
{t(
|
||||||
|
job.state === "interrupted"
|
||||||
|
? "retryUnfinished"
|
||||||
|
: "retryFailed",
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||||
export function useFurnitureJobs(onComplete: () => void) {
|
export function useFurnitureJobs(onComplete: () => void) {
|
||||||
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
|
|||||||
callback.current = onComplete;
|
callback.current = onComplete;
|
||||||
const request = useRef<{ payload: string; id: string } | null>(null);
|
const request = useRef<{ payload: string; id: string } | null>(null);
|
||||||
const submitting = useRef(false);
|
const submitting = useRef(false);
|
||||||
|
const refreshing = useRef(false);
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (refreshing.current) return;
|
||||||
|
refreshing.current = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
|
|||||||
setError(
|
setError(
|
||||||
error instanceof Error ? error.message : "Cannot load import history",
|
error instanceof Error ? error.message : "Cannot load import history",
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
refreshing.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useVisiblePolling(refresh, 5000);
|
||||||
void refresh();
|
|
||||||
const timer = setInterval(() => void refresh(), 5000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [refresh]);
|
|
||||||
const submit = async (
|
const submit = async (
|
||||||
items: ImportJobItem[],
|
items: ImportJobItem[],
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
@@ -35,13 +36,19 @@ interface RunOptions {
|
|||||||
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
|
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
|
||||||
*/
|
*/
|
||||||
export function useServerAction(options: UseServerActionOptions = {}) {
|
export function useServerAction(options: UseServerActionOptions = {}) {
|
||||||
|
const t = useTranslations("pages.admin.actions");
|
||||||
|
const locked = useRef(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
|
function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
|
||||||
|
if (locked.current) return;
|
||||||
|
locked.current = true;
|
||||||
|
setFieldErrors({});
|
||||||
const {
|
const {
|
||||||
successMessage = "Saved successfully.",
|
successMessage = t("saved"),
|
||||||
errorMessage = "An error occurred.",
|
errorMessage = t("error"),
|
||||||
redirectTo,
|
redirectTo,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
@@ -61,16 +68,24 @@ export function useServerAction(options: UseServerActionOptions = {}) {
|
|||||||
else router.refresh();
|
else router.refresh();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || errorMessage);
|
setFieldErrors(result.fieldErrors ?? {});
|
||||||
|
toast.error(result.error || errorMessage, {
|
||||||
|
description:
|
||||||
|
Object.values(result.fieldErrors ?? {})
|
||||||
|
.flat()
|
||||||
|
.join(" · ") || undefined,
|
||||||
|
});
|
||||||
onError?.();
|
onError?.();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[useServerAction] Unexpected error:", err);
|
console.error("[useServerAction] Unexpected error:", err);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
onError?.();
|
onError?.();
|
||||||
|
} finally {
|
||||||
|
locked.current = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { run, isPending };
|
return { run, isPending, fieldErrors };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
/** Protect browser close/reload and same-tab link navigation while editing. */
|
||||||
|
export function useUnsavedChanges(dirty: boolean, message: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
function beforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
}
|
||||||
|
function click(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.altKey
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const link =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest("a[href]")
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
!(link instanceof HTMLAnchorElement) ||
|
||||||
|
link.hasAttribute("download") ||
|
||||||
|
(link.target && link.target !== "_self")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const target = new URL(link.href, location.href);
|
||||||
|
if (
|
||||||
|
target.origin === location.origin &&
|
||||||
|
target.pathname === location.pathname &&
|
||||||
|
target.search === location.search
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (!window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", beforeUnload);
|
||||||
|
document.addEventListener("click", click, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", beforeUnload);
|
||||||
|
document.removeEventListener("click", click, true);
|
||||||
|
};
|
||||||
|
}, [dirty, message]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
/** Refresh after the prior request settles; pause while the tab is hidden. */
|
||||||
|
export function useVisiblePolling(
|
||||||
|
refresh: () => Promise<unknown>,
|
||||||
|
delay: number,
|
||||||
|
) {
|
||||||
|
const callback = useRef(refresh);
|
||||||
|
callback.current = refresh;
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false,
|
||||||
|
running = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
async function tick() {
|
||||||
|
if (stopped || running) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await callback.current();
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (!stopped) timer = setTimeout(tick, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function visibility() {
|
||||||
|
if (document.visibilityState === "visible") void tick();
|
||||||
|
else clearTimeout(timer);
|
||||||
|
}
|
||||||
|
void tick();
|
||||||
|
document.addEventListener("visibilitychange", visibility);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener("visibilitychange", visibility);
|
||||||
|
};
|
||||||
|
}, [delay]);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createTranslator } from "next-intl";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||||
|
import { visibleAdminNavigation } from "@/lib/admin-navigation";
|
||||||
|
import en from "@/messages/en.json";
|
||||||
|
import itMessages from "@/messages/it.json";
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["en", en],
|
||||||
|
["it", itMessages],
|
||||||
|
] as const)(
|
||||||
|
"renders housekeeping navigation and shared controls in %s",
|
||||||
|
(locale, messages) => {
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const t = createTranslator({
|
||||||
|
locale,
|
||||||
|
messages: messages as unknown as Record<string, string>,
|
||||||
|
onError: (error) => errors.push(error),
|
||||||
|
});
|
||||||
|
const translate = t as unknown as (
|
||||||
|
key: string,
|
||||||
|
values?: Record<string, string | number>,
|
||||||
|
) => string;
|
||||||
|
const keys = new Set([
|
||||||
|
...ADMIN_NAV_GROUPS.flatMap((g) => [
|
||||||
|
g.labelKey,
|
||||||
|
...g.items.map((i) => i.labelKey),
|
||||||
|
]),
|
||||||
|
...visibleAdminNavigation({
|
||||||
|
allowedPermissions: [],
|
||||||
|
isSuperAdmin: true,
|
||||||
|
navConfig: {},
|
||||||
|
}).map((g) => g.labelKey),
|
||||||
|
]);
|
||||||
|
for (const key of keys) translate(`pages.admin.nav.${key}`);
|
||||||
|
for (const area of ["actions", "table", "cmsErrors", "workQueue"] as const)
|
||||||
|
for (const key of Object.keys(en.pages.admin[area]))
|
||||||
|
translate(`pages.admin.${area}.${key}`, {
|
||||||
|
count: 2,
|
||||||
|
action: "Save",
|
||||||
|
page: 2,
|
||||||
|
last: 7,
|
||||||
|
id: "1",
|
||||||
|
name: "Name",
|
||||||
|
groups: 1,
|
||||||
|
});
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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
@@ -1,41 +1,17 @@
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { loadCmsMessages } from "@/lib/cms-translations";
|
||||||
import { mergeMessages } from "./merge-messages";
|
import { mergeMessages } from "./merge-messages";
|
||||||
|
|
||||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
||||||
// the Accept-Language header, and finally English as fallback.
|
// the Accept-Language header, and finally English as fallback.
|
||||||
export const SUPPORTED_LOCALES = [
|
export {
|
||||||
"en",
|
DEFAULT_LOCALE,
|
||||||
"it",
|
isSupportedLocale,
|
||||||
"nl",
|
SUPPORTED_LOCALES,
|
||||||
"de",
|
} from "./locales";
|
||||||
"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(
|
import { type AppLocale, DEFAULT_LOCALE, isSupportedLocale } from "./locales";
|
||||||
value: string | undefined,
|
|
||||||
): value is AppLocale {
|
|
||||||
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectLocaleFromAcceptLanguage(
|
function detectLocaleFromAcceptLanguage(
|
||||||
acceptLanguage: string | null,
|
acceptLanguage: string | null,
|
||||||
@@ -73,14 +49,10 @@ export default getRequestConfig(async () => {
|
|||||||
locale = detected ?? DEFAULT_LOCALE;
|
locale = detected ?? DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe: `locale` is validated against SUPPORTED_LOCALES above (only en/it/nl/de/fr/es).
|
const [messages, fallback] = await Promise.all([
|
||||||
const messages = (await import(`../messages/${locale}.json`)).default;
|
loadCmsMessages(locale),
|
||||||
// English is the source of truth; fall back to it for any key missing from a
|
loadCmsMessages(DEFAULT_LOCALE),
|
||||||
// translation so the UI never shows a raw key path.
|
]);
|
||||||
const fallback =
|
|
||||||
locale === DEFAULT_LOCALE
|
|
||||||
? messages
|
|
||||||
: (await import(`../messages/${DEFAULT_LOCALE}.json`)).default;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
@@ -108,6 +80,7 @@ export default getRequestConfig(async () => {
|
|||||||
if (typeof fromEnglish === "string") return fromEnglish;
|
if (typeof fromEnglish === "string") return fromEnglish;
|
||||||
const seg = key.split(".").pop() ?? key;
|
const seg = key.split(".").pop() ?? key;
|
||||||
return seg
|
return seg
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
.replace(/[._-]/g, " ")
|
.replace(/[._-]/g, " ")
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-1
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
icon: Server,
|
icon: Server,
|
||||||
prefixes: ["/admin/devops"],
|
prefixes: ["/admin/devops"],
|
||||||
tabs: [
|
tabs: [
|
||||||
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
|
{
|
||||||
|
href: "/admin/devops",
|
||||||
|
labelKey: "devops",
|
||||||
|
match: ["/admin/devops"],
|
||||||
|
},
|
||||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||||
|
{ href: "/admin/devops/cms-errors", labelKey: "cmsErrors" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
icon: Server,
|
icon: Server,
|
||||||
permission: PERMS.DEVOPS_VIEW,
|
permission: PERMS.DEVOPS_VIEW,
|
||||||
matchPrefixes: ["/admin/devops"],
|
matchPrefixes: ["/admin/devops"],
|
||||||
|
matchExcludePrefixes: ["/admin/devops/cms-errors"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/devops/cms-errors",
|
||||||
|
labelKey: "cmsErrors",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
permission: PERMS.DEVOPS_VIEW,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/alerts",
|
href: "/admin/alerts",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||||
|
import { visibleAdminNavigation } from "./admin-navigation";
|
||||||
|
|
||||||
|
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
|
||||||
|
it("retains each route and its permission definition once after regrouping", () => {
|
||||||
|
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
|
||||||
|
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
|
||||||
|
expect(actual).toHaveLength(original.length);
|
||||||
|
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
|
||||||
|
for (const item of original) expect(actual).toContain(item);
|
||||||
|
expect(
|
||||||
|
visibleAdminNavigation(access)
|
||||||
|
.find((g) => g.labelKey === "catalogFurniture")
|
||||||
|
?.items.some((i) => i.href === "/admin/studio"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("respects hidden groups, owner order and ACL in every consumer", () => {
|
||||||
|
const configured = visibleAdminNavigation({
|
||||||
|
...access,
|
||||||
|
navConfig: {
|
||||||
|
groupOrder: ["monitoring"],
|
||||||
|
hiddenGroups: ["economy"],
|
||||||
|
hiddenItems: ["/admin/studio"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(configured[0].labelKey).toBe("monitoring");
|
||||||
|
expect(
|
||||||
|
configured
|
||||||
|
.flatMap((g) => g.items)
|
||||||
|
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
|
||||||
|
).toBe(false);
|
||||||
|
const limited = visibleAdminNavigation({
|
||||||
|
...access,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
}).flatMap((g) => g.items);
|
||||||
|
expect(limited.every((i) => !i.permission)).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Cog,
|
||||||
|
LayoutDashboard,
|
||||||
|
Newspaper,
|
||||||
|
Package,
|
||||||
|
ShoppingCart,
|
||||||
|
Users,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_GROUPS,
|
||||||
|
type AdminNavGroup,
|
||||||
|
navItemIsAllowed,
|
||||||
|
} from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
type AdminNavConfig,
|
||||||
|
applyAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
|
||||||
|
export type AdminNavigationOptions = {
|
||||||
|
allowedPermissions: string[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
navConfig: AdminNavConfig;
|
||||||
|
};
|
||||||
|
const sections = [
|
||||||
|
{ labelKey: "overview", icon: LayoutDashboard, roots: [""] },
|
||||||
|
{
|
||||||
|
labelKey: "peopleSupport",
|
||||||
|
icon: Users,
|
||||||
|
roots: [
|
||||||
|
"users",
|
||||||
|
"online",
|
||||||
|
"tickets",
|
||||||
|
"help-tickets",
|
||||||
|
"bans",
|
||||||
|
"ip",
|
||||||
|
"vpn",
|
||||||
|
"wordfilter",
|
||||||
|
"moderation",
|
||||||
|
"rooms",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "catalogFurniture",
|
||||||
|
icon: Package,
|
||||||
|
roots: [
|
||||||
|
"studio",
|
||||||
|
"catalog",
|
||||||
|
"import",
|
||||||
|
"items",
|
||||||
|
"rare-values",
|
||||||
|
"badges",
|
||||||
|
"achievements",
|
||||||
|
"sounds",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "communityContent",
|
||||||
|
icon: Newspaper,
|
||||||
|
roots: [
|
||||||
|
"articles",
|
||||||
|
"photos",
|
||||||
|
"events",
|
||||||
|
"polls",
|
||||||
|
"guilds",
|
||||||
|
"media",
|
||||||
|
"radio",
|
||||||
|
"ads",
|
||||||
|
"banners",
|
||||||
|
"navigation",
|
||||||
|
"help-questions",
|
||||||
|
"writeable-boxes",
|
||||||
|
"tags",
|
||||||
|
"prefixes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "economy",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
roots: [
|
||||||
|
"shop",
|
||||||
|
"marketplace",
|
||||||
|
"transactions",
|
||||||
|
"vouchers",
|
||||||
|
"subscriptions",
|
||||||
|
"calendar",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "staffPermissions",
|
||||||
|
icon: UsersRound,
|
||||||
|
roots: ["teams", "applications", "permissions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "system",
|
||||||
|
icon: Cog,
|
||||||
|
roots: [
|
||||||
|
"settings",
|
||||||
|
"theme",
|
||||||
|
"theme-builder",
|
||||||
|
"maintenance",
|
||||||
|
"favicon",
|
||||||
|
"email-templates",
|
||||||
|
"translations",
|
||||||
|
"emulator",
|
||||||
|
"housekeeping",
|
||||||
|
"commandocentrum",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "monitoring",
|
||||||
|
icon: Activity,
|
||||||
|
roots: ["logs", "analytics", "devops", "alerts"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Owner order takes precedence; hidden entries are filtered before regrouping. */
|
||||||
|
export function visibleAdminNavigation(
|
||||||
|
options: AdminNavigationOptions,
|
||||||
|
): AdminNavGroup[] {
|
||||||
|
const allowed = new Set(options.allowedPermissions);
|
||||||
|
const groups = applyAdminNavConfig(ADMIN_NAV_GROUPS, options.navConfig)
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) =>
|
||||||
|
navItemIsAllowed(item, {
|
||||||
|
isSuperAdmin: options.isSuperAdmin,
|
||||||
|
has: (slug) => allowed.has(slug),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length);
|
||||||
|
if (
|
||||||
|
options.navConfig.groupOrder?.length ||
|
||||||
|
Object.values(options.navConfig.itemOrder ?? {}).some(
|
||||||
|
(order) => order.length,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return groups;
|
||||||
|
const remaining = new Map(
|
||||||
|
groups.flatMap((group) => group.items).map((item) => [item.href, item]),
|
||||||
|
);
|
||||||
|
const organized = sections
|
||||||
|
.map((section) => {
|
||||||
|
const items = [...remaining.values()].filter((item) =>
|
||||||
|
section.roots.includes(item.href.split("/")[2] ?? ""),
|
||||||
|
);
|
||||||
|
for (const item of items) remaining.delete(item.href);
|
||||||
|
return { labelKey: section.labelKey, icon: section.icon, items };
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length);
|
||||||
|
// New routes remain discoverable even before being assigned to an activity.
|
||||||
|
if (remaining.size)
|
||||||
|
organized.push({
|
||||||
|
labelKey: "tools",
|
||||||
|
icon: Cog,
|
||||||
|
items: [...remaining.values()],
|
||||||
|
});
|
||||||
|
return organized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: new Set<string>(),
|
||||||
|
queried: [] as unknown[],
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api-handler", () => ({
|
||||||
|
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
|
||||||
|
handler(request, {
|
||||||
|
session: { user: { id: 1, rank: 7 } },
|
||||||
|
permissions: { isSuperAdmin: false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
const tables = Object.fromEntries(
|
||||||
|
[
|
||||||
|
"User",
|
||||||
|
"Rooms",
|
||||||
|
"Guilds",
|
||||||
|
"WebsiteArticles",
|
||||||
|
"WebsiteRareValues",
|
||||||
|
"WebsiteShopArticles",
|
||||||
|
].map((name) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
id: `${name}Id`,
|
||||||
|
username: `${name}Name`,
|
||||||
|
mail: `${name}Mail`,
|
||||||
|
slug: `${name}Slug`,
|
||||||
|
ownerName: `${name}Owner`,
|
||||||
|
itemId: `${name}Item`,
|
||||||
|
description: `${name}Description`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...tables,
|
||||||
|
db: {
|
||||||
|
select() {
|
||||||
|
let table: unknown;
|
||||||
|
const query = {
|
||||||
|
from(value: unknown) {
|
||||||
|
table = value;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
where() {
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
async limit() {
|
||||||
|
state.queried.push(table);
|
||||||
|
return [{ id: 1, title: "Match", subtitle: "Data" }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { GET } from "@/app/api/admin/search/route";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.allowed.clear();
|
||||||
|
state.queried.length = 0;
|
||||||
|
});
|
||||||
|
const request = () =>
|
||||||
|
({
|
||||||
|
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
|
||||||
|
}) as Parameters<typeof GET>[0];
|
||||||
|
it("does not query or expose categories without their view permission", async () => {
|
||||||
|
const response = await GET(request(), {});
|
||||||
|
expect((await response.json()).results).toEqual([]);
|
||||||
|
expect(state.queried).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it("searches only the permitted category", async () => {
|
||||||
|
state.allowed.add(PERMS.NEWS_VIEW);
|
||||||
|
const response = await GET(request(), {});
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.results).toHaveLength(1);
|
||||||
|
expect(body.results[0].type).toBe("articles");
|
||||||
|
expect(state.queried).toHaveLength(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { tablePageNumbers } from "./admin-table-state";
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[1, 1],
|
||||||
|
[1, 7],
|
||||||
|
[6, 7],
|
||||||
|
[7, 7],
|
||||||
|
[50, 100],
|
||||||
|
[100, 100],
|
||||||
|
])("includes the current page %i/%i and stays in bounds", (page, last) => {
|
||||||
|
const pages = tablePageNumbers(page, last);
|
||||||
|
expect(pages).toContain(page);
|
||||||
|
expect(pages.length).toBeLessThanOrEqual(5);
|
||||||
|
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
|
||||||
|
expect(new Set(pages).size).toBe(pages.length);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** A centered, bounded window including the current page, also at either end. */
|
||||||
|
export function tablePageNumbers(page: number, last: number): number[] {
|
||||||
|
const count = Math.min(5, Math.max(1, last));
|
||||||
|
const start = Math.max(1, Math.min(page - 2, last - count + 1));
|
||||||
|
return Array.from({ length: count }, (_, index) => start + index);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
export const loadAdminNavigation = cache(async () => {
|
||||||
|
const [{ session, permissions }, raw] = await Promise.all([
|
||||||
|
getAdminContext(),
|
||||||
|
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
]);
|
||||||
|
const hasDashboard = canAccess(
|
||||||
|
permissions,
|
||||||
|
PERMS.ADMIN_DASHBOARD,
|
||||||
|
session.user.rank,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isSuperAdmin: permissions.isSuperAdmin,
|
||||||
|
allowedPermissions: permissions.isSuperAdmin
|
||||||
|
? []
|
||||||
|
: collectNavPermissionSlugs().filter(
|
||||||
|
(slug) =>
|
||||||
|
canAccess(permissions, slug, session.user.rank) ||
|
||||||
|
(hasDashboard &&
|
||||||
|
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
|
||||||
|
),
|
||||||
|
navConfig: parseAdminNavConfig(raw),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { articleSlug, readArticleInput } from "./article-input";
|
||||||
|
|
||||||
|
it("normalizes Next catch-all slug parameters", () => {
|
||||||
|
expect(articleSlug(["welcome"])).toBe("welcome");
|
||||||
|
expect(articleSlug("welcome")).toBe("welcome");
|
||||||
|
});
|
||||||
|
it("keeps the selected timezone instant when scheduling", () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("title", "News");
|
||||||
|
form.set("status", "scheduled");
|
||||||
|
form.set("publishAt", "2026-09-06T20:00:00+02:00");
|
||||||
|
expect(readArticleInput(form).publishAt?.toISOString()).toBe(
|
||||||
|
"2026-09-06T18:00:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("ships additive publication columns for existing installations", () => {
|
||||||
|
const sql = readFileSync(
|
||||||
|
"drizzle/migrations/0025_article_publication.sql",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const column of ["status", "publish_at", "published_at"])
|
||||||
|
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS ${column}`);
|
||||||
|
expect(sql).toContain("DEFAULT 'published'");
|
||||||
|
expect(sql).not.toMatch(/DROP|UPDATE\s+website_articles/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ActionResult } from "./safe-action-shared";
|
||||||
|
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||||
|
export class ArticleInputError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code:
|
||||||
|
| "titleRequired"
|
||||||
|
| "titleTooLong"
|
||||||
|
| "summaryTooLong"
|
||||||
|
| "imageTooLong"
|
||||||
|
| "invalidStatus"
|
||||||
|
| "publishDateRequired",
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function readArticleInput(form: FormData) {
|
||||||
|
const text = (key: string) =>
|
||||||
|
String(form.get(key) ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const title = text("title"),
|
||||||
|
shortStory = text("shortStory"),
|
||||||
|
fullStory = text("fullStory"),
|
||||||
|
image = text("image");
|
||||||
|
if (!title) throw new ArticleInputError("titleRequired");
|
||||||
|
if (title.length > 255) throw new ArticleInputError("titleTooLong");
|
||||||
|
if (shortStory.length > 255) throw new ArticleInputError("summaryTooLong");
|
||||||
|
if (image.length > 255) throw new ArticleInputError("imageTooLong");
|
||||||
|
const status = text("status") || "published";
|
||||||
|
if (status !== "published" && status !== "draft" && status !== "scheduled")
|
||||||
|
throw new ArticleInputError("invalidStatus");
|
||||||
|
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||||
|
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||||
|
throw new ArticleInputError("publishDateRequired");
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
shortStory,
|
||||||
|
fullStory,
|
||||||
|
image,
|
||||||
|
status,
|
||||||
|
publishAt,
|
||||||
|
rawSlug: text("slug"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function articleSlug(value: string | string[]): string {
|
||||||
|
return Array.isArray(value) ? value.join("/") : value;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import type { ImportJob } from "./import-job";
|
||||||
|
import { retryableJobItems } from "./import-job-retry";
|
||||||
|
|
||||||
|
const job = (state: ImportJob["state"]) =>
|
||||||
|
({
|
||||||
|
state,
|
||||||
|
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||||
|
state,
|
||||||
|
classname: state,
|
||||||
|
})),
|
||||||
|
}) as ImportJob;
|
||||||
|
it("only retries failures after a completed job", () =>
|
||||||
|
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||||
|
"failed",
|
||||||
|
]));
|
||||||
|
it("recovers all unfinished items from an interrupted job", () =>
|
||||||
|
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||||
|
"failed",
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
]));
|
||||||
|
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||||
|
expect(retryableJobItems(job(state))).toEqual([]),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ImportJob } from "./import-job";
|
||||||
|
/** Never requeue an active job or furniture already imported successfully. */
|
||||||
|
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||||
|
if (job.state === "running" || job.state === "queued") return [];
|
||||||
|
return job.items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.state === "failed" ||
|
||||||
|
(job.state === "interrupted" && item.state !== "done"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
revision: "0",
|
||||||
|
values: new Map<string, unknown>(),
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
status: "ready",
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/redis", () => ({ redis: state }));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/cache", () => ({
|
||||||
|
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
|
||||||
|
if (state.values.has(key)) return state.values.get(key);
|
||||||
|
const result = await query();
|
||||||
|
state.values.set(key, result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { cacheNews, invalidateNewsCache } from "./news-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.values.clear();
|
||||||
|
state.revision = "0";
|
||||||
|
state.status = "ready";
|
||||||
|
state.get.mockReset().mockImplementation(async () => state.revision);
|
||||||
|
state.set
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (_key: string, value: string) => {
|
||||||
|
state.revision = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("invalidates a previously cached public list", async () => {
|
||||||
|
const query = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce(["new article"]);
|
||||||
|
expect(await cacheNews("list", 60000, query)).toEqual([]);
|
||||||
|
await invalidateNewsCache();
|
||||||
|
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
|
||||||
|
});
|
||||||
|
it("does not let a stale in-flight read replace a newer revision", async () => {
|
||||||
|
let finish!: (value: string[]) => void;
|
||||||
|
const old = cacheNews(
|
||||||
|
"list",
|
||||||
|
60000,
|
||||||
|
() =>
|
||||||
|
new Promise<string[]>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||||
|
await invalidateNewsCache();
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
|
||||||
|
finish([]);
|
||||||
|
await old;
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
|
||||||
|
"new",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("reads fresh data when Redis is unavailable", async () => {
|
||||||
|
state.get.mockRejectedValue(Error("offline"));
|
||||||
|
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
|
||||||
|
"fresh",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { cached } from "@/lib/cache";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
const REVISION_KEY = "cms:news:revision";
|
||||||
|
export async function cacheNews<T>(
|
||||||
|
key: string,
|
||||||
|
ttlMs: number,
|
||||||
|
query: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!redis || redis.status === "end") return query();
|
||||||
|
let revision: string;
|
||||||
|
try {
|
||||||
|
revision = (await redis.get(REVISION_KEY)) ?? "0";
|
||||||
|
} catch {
|
||||||
|
return query();
|
||||||
|
}
|
||||||
|
return cached(`news:${revision}:${key}`, ttlMs, query);
|
||||||
|
}
|
||||||
|
export async function invalidateNewsCache(): Promise<void> {
|
||||||
|
if (!redis || redis.status === "end") return;
|
||||||
|
try {
|
||||||
|
await redis.set(REVISION_KEY, randomUUID());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("News saved but public cache invalidation failed", {
|
||||||
|
module: "news",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { cached } from "@/lib/cache";
|
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
|
||||||
export interface NewsListItem {
|
export interface NewsListItem {
|
||||||
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
|
|||||||
*/
|
*/
|
||||||
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||||
try {
|
try {
|
||||||
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
slug: WebsiteArticles.slug,
|
slug: WebsiteArticles.slug,
|
||||||
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
|||||||
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
createdAt: a.createdAt ? new Date(a.createdAt) : null,
|
||||||
}))
|
}))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error("Public news query failed", { module: "news", error });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
friends: [] as { id: number; username: string; online: string }[],
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
|
||||||
|
vi.mock("./site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
get: async (_key: string, fallback: string) => fallback,
|
||||||
|
getMany: async () => ({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
select: (fields: Record<string, unknown>) => {
|
||||||
|
let rows: unknown[] = [];
|
||||||
|
const chain = {
|
||||||
|
from: (table: unknown) => {
|
||||||
|
if (table === schema.MessengerFriendships)
|
||||||
|
rows =
|
||||||
|
"value" in fields
|
||||||
|
? [{ value: state.friends.length }]
|
||||||
|
: state.friends;
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
where: () => chain,
|
||||||
|
innerJoin: () => chain,
|
||||||
|
orderBy: () => chain,
|
||||||
|
limit: () => chain,
|
||||||
|
catch: () => Promise.resolve(rows),
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
|
||||||
|
then: (resolve: (value: unknown[]) => unknown) =>
|
||||||
|
Promise.resolve(rows).then(resolve),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { loadUserDashboard } from "./user-dashboard";
|
||||||
|
|
||||||
|
describe("dashboard friendships", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.friends = [];
|
||||||
|
});
|
||||||
|
it("counts each friend once when both directional rows are returned", async () => {
|
||||||
|
const wait = { id: 2, username: "wait", online: "1" };
|
||||||
|
const other = { id: 3, username: "YouKnowMe", online: "1" };
|
||||||
|
state.friends = [wait, other, { ...wait }, { ...other }];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||||
|
});
|
||||||
|
it("preserves single-direction and offline friendships", async () => {
|
||||||
|
state.friends = [
|
||||||
|
{ id: 2, username: "wait", online: "1" },
|
||||||
|
{ id: 3, username: "offline", online: "0" },
|
||||||
|
];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual(state.friends);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||||
|
});
|
||||||
|
it("excludes self friendships", async () => {
|
||||||
|
state.friends = [{ id: 1, username: "me", online: "1" }];
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual([]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||||
|
});
|
||||||
|
it("returns zero for an empty friends list", async () => {
|
||||||
|
const data = await loadUserDashboard(1);
|
||||||
|
expect(data.friends).toEqual([]);
|
||||||
|
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
recentRooms,
|
recentRooms,
|
||||||
badges,
|
badges,
|
||||||
userSettingsRows,
|
userSettingsRows,
|
||||||
friendCountRows,
|
|
||||||
unreadCountRows,
|
unreadCountRows,
|
||||||
referralsRows,
|
referralsRows,
|
||||||
friends,
|
friends,
|
||||||
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
.where(eq(UsersSettings.userId, userId))
|
.where(eq(UsersSettings.userId, userId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(MessengerFriendships)
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
eq(MessengerFriendships.userOneId, userId),
|
|
||||||
eq(MessengerFriendships.userTwoId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => [{ value: 0 }]),
|
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(MessengerOffline)
|
.from(MessengerOffline)
|
||||||
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Each friendship can have two directional rows; expose users, not edges.
|
||||||
|
const uniqueFriends = [
|
||||||
|
...new Map(
|
||||||
|
friends
|
||||||
|
.filter((friend) => friend.id !== userId)
|
||||||
|
.map((friend) => [friend.id, friend]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userRows,
|
userRows,
|
||||||
hotelName,
|
hotelName,
|
||||||
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
|
|||||||
recentRooms,
|
recentRooms,
|
||||||
badges,
|
badges,
|
||||||
userSettingsRows,
|
userSettingsRows,
|
||||||
friendCountRows,
|
friendCountRows: [{ value: uniqueFriends.length }],
|
||||||
unreadCountRows,
|
unreadCountRows,
|
||||||
referralsRows,
|
referralsRows,
|
||||||
friends,
|
friends: uniqueFriends,
|
||||||
currencyRows,
|
currencyRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
|
|||||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
|
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
|
||||||
"/admin/menu",
|
"/admin/menu",
|
||||||
);
|
);
|
||||||
expect(
|
expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
|
||||||
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
"applyAdminNavConfig",
|
||||||
).toContain("applyAdminNavConfig");
|
);
|
||||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
|
||||||
"ADMIN_NAV_CONFIG_KEY",
|
"ADMIN_NAV_CONFIG_KEY",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "няма зададен API за игра в момента",
|
"noNowPlayingHint": "няма зададен API за игра в момента",
|
||||||
"noListenersHint": "няма зададен API за слушатели",
|
"noListenersHint": "няма зададен API за слушатели",
|
||||||
"endpointDiagnostics": "Диагностика на крайна точка",
|
"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 адрес на потока",
|
"streamUrl": "URL адрес на потока",
|
||||||
"unreachable": "недостижим",
|
"unreachable": "недостижим",
|
||||||
"refresh": "Опресняване"
|
"refresh": "Опресняване"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Няма зададен radio_stream_url, така че все още няма нищо за вграждане.",
|
"noStreamUrlText": "Няма зададен radio_stream_url, така че все още няма нищо за вграждане.",
|
||||||
"goToSettings": "Отидете на Радио настройки",
|
"goToSettings": "Отидете на Радио настройки",
|
||||||
"livePreview": "Преглед на живо",
|
"livePreview": "Преглед на живо",
|
||||||
"livePreviewText": "Това е голият <audio> плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
|
"livePreviewText": "Това е голият audio плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
|
||||||
"source": "Източник",
|
"source": "Източник",
|
||||||
"audioSnippet": "Фрагмент от аудио плейър",
|
"audioSnippet": "Фрагмент от аудио плейър",
|
||||||
"audioSnippetDesc": "Вмъкващ се HTML5 аудио плейър, насочен право към потока. Работи навсякъде, където е разрешен обикновен HTML.",
|
"audioSnippetDesc": "Вмъкващ се HTML5 аудио плейър, насочен право към потока. Работи навсякъде, където е разрешен обикновен HTML.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Сигнали",
|
"title": "Сигнали",
|
||||||
"subtitle": "Изпращайте сигнали за целия хотел и вижте последните сигнали",
|
"subtitle": "Изпращайте сигнали за целия хотел и вижте последните сигнали",
|
||||||
"sendAlert": "Изпратете сигнал за хотел",
|
"sendAlert": "Изпратете сигнал за хотел",
|
||||||
"recentAlerts": "Последни сигнали",
|
"recentAlerts": "Последни сигнали ({count})",
|
||||||
"noRecentAlerts": "Няма скорошни сигнали",
|
"noRecentAlerts": "Няма скорошни сигнали",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Заглавие на предупреждение",
|
"title": "Заглавие на предупреждение",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Импортиране Разрешения",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Импортиране",
|
"import": "Импортиране",
|
||||||
"exportTitle": "Export Разрешения",
|
"exportTitle": "Export Разрешения",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Предварително зададени теми",
|
"presetsTitle": "Предварително зададени теми",
|
||||||
"presetsSubtitle": "Изберете предварително изградена цветова схема",
|
"presetsSubtitle": "Изберете предварително изградена цветова схема",
|
||||||
"applyPreset": "Кандидатствайте",
|
"applyPreset": "Кандидатствайте",
|
||||||
"presetApplied": "Предварителната настройка е приложена",
|
"presetApplied": "Предварителната настройка е приложена: {preset}",
|
||||||
"customiseTitle": "Персонализирани цветове",
|
"customiseTitle": "Персонализирани цветове",
|
||||||
"primary": "Първичен",
|
"primary": "Първичен",
|
||||||
"secondary": "Вторичен",
|
"secondary": "Вторичен",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "žádná nyní přehrávající sada API",
|
"noNowPlayingHint": "žádná nyní přehrávající sada API",
|
||||||
"noListenersHint": "není nastaveno žádné rozhraní API pro posluchače",
|
"noListenersHint": "není nastaveno žádné rozhraní API pro posluchače",
|
||||||
"endpointDiagnostics": "Diagnostika koncových bodů",
|
"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",
|
"streamUrl": "Adresa URL streamu",
|
||||||
"unreachable": "nedosažitelný",
|
"unreachable": "nedosažitelný",
|
||||||
"refresh": "Obnovit"
|
"refresh": "Obnovit"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Neexistuje žádná sada radio_stream_url, takže zatím není co vkládat.",
|
"noStreamUrlText": "Neexistuje žádná sada radio_stream_url, takže zatím není co vkládat.",
|
||||||
"goToSettings": "Přejděte do Nastavení rádia",
|
"goToSettings": "Přejděte do Nastavení rádia",
|
||||||
"livePreview": "Živý náhled",
|
"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",
|
"source": "Zdroj",
|
||||||
"audioSnippet": "Úryvek audio přehrávače",
|
"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.",
|
"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í",
|
"title": "Upozornění",
|
||||||
"subtitle": "Odesílejte upozornění pro celý hotel a prohlížejte si nedávná upozornění",
|
"subtitle": "Odesílejte upozornění pro celý hotel a prohlížejte si nedávná upozornění",
|
||||||
"sendAlert": "Odeslat upozornění na hotel",
|
"sendAlert": "Odeslat upozornění na hotel",
|
||||||
"recentAlerts": "Nedávná upozornění",
|
"recentAlerts": "Nedávná upozornění ({count})",
|
||||||
"noRecentAlerts": "Žádná nedávná upozornění",
|
"noRecentAlerts": "Žádná nedávná upozornění",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Název upozornění",
|
"title": "Název upozornění",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importovat Oprávnění",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importovat",
|
"import": "Importovat",
|
||||||
"exportTitle": "Export Oprávnění",
|
"exportTitle": "Export Oprávnění",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Předvolby motivu",
|
"presetsTitle": "Předvolby motivu",
|
||||||
"presetsSubtitle": "Vyberte předem připravené barevné schéma",
|
"presetsSubtitle": "Vyberte předem připravené barevné schéma",
|
||||||
"applyPreset": "Použít",
|
"applyPreset": "Použít",
|
||||||
"presetApplied": "Předvolba použita",
|
"presetApplied": "Předvolba použita: {preset}",
|
||||||
"customiseTitle": "Vlastní barvy",
|
"customiseTitle": "Vlastní barvy",
|
||||||
"primary": "Primární",
|
"primary": "Primární",
|
||||||
"secondary": "Sekundární",
|
"secondary": "Sekundární",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "intet API-sæt, der nu spiller",
|
"noNowPlayingHint": "intet API-sæt, der nu spiller",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Slutpunktsdiagnostik",
|
"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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Opdater"
|
"refresh": "Opdater"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Der er ingen radio_stream_url sat, så der er intet at indlejre endnu.",
|
"noStreamUrlText": "Der er ingen radio_stream_url sat, så der er intet at indlejre endnu.",
|
||||||
"goToSettings": "Gå til Radioindstillinger",
|
"goToSettings": "Gå til Radioindstillinger",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Kilde",
|
||||||
"audioSnippet": "Uddrag af lydafspiller",
|
"audioSnippet": "Uddrag af lydafspiller",
|
||||||
"audioSnippetDesc": "Drop-in HTML5-lydafspiller pegede direkte mod strømmen. Virker overalt, hvor almindelig HTML er tilladt.",
|
"audioSnippetDesc": "Drop-in HTML5-lydafspiller pegede direkte mod strømmen. Virker overalt, hvor almindelig HTML er tilladt.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Advarsler",
|
"title": "Advarsler",
|
||||||
"subtitle": "Send underretninger for hele hotellet, og se de seneste underretninger",
|
"subtitle": "Send underretninger for hele hotellet, og se de seneste underretninger",
|
||||||
"sendAlert": "Send hotelalarm",
|
"sendAlert": "Send hotelalarm",
|
||||||
"recentAlerts": "Seneste underretninger",
|
"recentAlerts": "Seneste underretninger ({count})",
|
||||||
"noRecentAlerts": "No recent alerts",
|
"noRecentAlerts": "No recent alerts",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Alert title",
|
"title": "Alert title",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importer Tilladelser",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
"exportTitle": "Export Tilladelser",
|
"exportTitle": "Export Tilladelser",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Forudindstillinger af tema",
|
"presetsTitle": "Forudindstillinger af tema",
|
||||||
"presetsSubtitle": "Vælg et forudbygget farveskema",
|
"presetsSubtitle": "Vælg et forudbygget farveskema",
|
||||||
"applyPreset": "Ansøg",
|
"applyPreset": "Ansøg",
|
||||||
"presetApplied": "Forudindstilling anvendt",
|
"presetApplied": "Forudindstilling anvendt: {preset}",
|
||||||
"customiseTitle": "Brugerdefinerede farver",
|
"customiseTitle": "Brugerdefinerede farver",
|
||||||
"primary": "Primær",
|
"primary": "Primær",
|
||||||
"secondary": "Sekundær",
|
"secondary": "Sekundær",
|
||||||
|
|||||||
@@ -1679,7 +1679,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Aktualisieren"
|
"refresh": "Aktualisieren"
|
||||||
@@ -1738,7 +1738,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -1969,7 +1969,7 @@
|
|||||||
"title": "Alarme",
|
"title": "Alarme",
|
||||||
"subtitle": "Hotelweite Alarme senden und aktuelle anzeigen",
|
"subtitle": "Hotelweite Alarme senden und aktuelle anzeigen",
|
||||||
"sendAlert": "Hotel-Alarm senden",
|
"sendAlert": "Hotel-Alarm senden",
|
||||||
"recentAlerts": "Letzte Alarme",
|
"recentAlerts": "Letzte Alarme ({count})",
|
||||||
"noRecentAlerts": "Keine aktuellen Alarme",
|
"noRecentAlerts": "Keine aktuellen Alarme",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Alarm-Titel",
|
"title": "Alarm-Titel",
|
||||||
@@ -2180,7 +2180,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importieren Berechtigungen",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
"exportTitle": "Export Berechtigungen",
|
"exportTitle": "Export Berechtigungen",
|
||||||
@@ -2317,7 +2317,7 @@
|
|||||||
"presetsTitle": "Design-Voreinstellungen",
|
"presetsTitle": "Design-Voreinstellungen",
|
||||||
"presetsSubtitle": "Wähle ein vorgefertigtes Farbschema",
|
"presetsSubtitle": "Wähle ein vorgefertigtes Farbschema",
|
||||||
"applyPreset": "Anwenden",
|
"applyPreset": "Anwenden",
|
||||||
"presetApplied": "Voreinstellung angewendet",
|
"presetApplied": "Voreinstellung angewendet: {preset}",
|
||||||
"customiseTitle": "Benutzerdefinierte Farben",
|
"customiseTitle": "Benutzerdefinierte Farben",
|
||||||
"primary": "Primär",
|
"primary": "Primär",
|
||||||
"secondary": "Sekundär",
|
"secondary": "Sekundär",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "δεν υπάρχει σύνολο API που παίζει τώρα",
|
"noNowPlayingHint": "δεν υπάρχει σύνολο API που παίζει τώρα",
|
||||||
"noListenersHint": "δεν έχει οριστεί API ακροατών",
|
"noListenersHint": "δεν έχει οριστεί API ακροατών",
|
||||||
"endpointDiagnostics": "Διαγνωστικά τελικού σημείου",
|
"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 ροής",
|
"streamUrl": "URL ροής",
|
||||||
"unreachable": "απρόσιτος",
|
"unreachable": "απρόσιτος",
|
||||||
"refresh": "Ανανέωση"
|
"refresh": "Ανανέωση"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Δεν υπάρχει set radio_stream_url, επομένως δεν υπάρχει τίποτα για ενσωμάτωση ακόμα.",
|
"noStreamUrlText": "Δεν υπάρχει set radio_stream_url, επομένως δεν υπάρχει τίποτα για ενσωμάτωση ακόμα.",
|
||||||
"goToSettings": "Μεταβείτε στις Ρυθμίσεις ραδιοφώνου",
|
"goToSettings": "Μεταβείτε στις Ρυθμίσεις ραδιοφώνου",
|
||||||
"livePreview": "Ζωντανή προεπισκόπηση",
|
"livePreview": "Ζωντανή προεπισκόπηση",
|
||||||
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής <audio> όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
|
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής audio όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
|
||||||
"source": "Πηγή",
|
"source": "Πηγή",
|
||||||
"audioSnippet": "Απόσπασμα αναπαραγωγής ήχου",
|
"audioSnippet": "Απόσπασμα αναπαραγωγής ήχου",
|
||||||
"audioSnippetDesc": "Το αναπτυσσόμενο πρόγραμμα αναπαραγωγής ήχου HTML5 κατευθείαν στη ροή. Λειτουργεί οπουδήποτε επιτρέπεται απλό HTML.",
|
"audioSnippetDesc": "Το αναπτυσσόμενο πρόγραμμα αναπαραγωγής ήχου HTML5 κατευθείαν στη ροή. Λειτουργεί οπουδήποτε επιτρέπεται απλό HTML.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Ειδοποιήσεις",
|
"title": "Ειδοποιήσεις",
|
||||||
"subtitle": "Στείλτε ειδοποιήσεις σε όλο το ξενοδοχείο και δείτε πρόσφατες ειδοποιήσεις",
|
"subtitle": "Στείλτε ειδοποιήσεις σε όλο το ξενοδοχείο και δείτε πρόσφατες ειδοποιήσεις",
|
||||||
"sendAlert": "Αποστολή ειδοποίησης ξενοδοχείου",
|
"sendAlert": "Αποστολή ειδοποίησης ξενοδοχείου",
|
||||||
"recentAlerts": "Πρόσφατες ειδοποιήσεις",
|
"recentAlerts": "Πρόσφατες ειδοποιήσεις ({count})",
|
||||||
"noRecentAlerts": "Δεν υπάρχουν πρόσφατες ειδοποιήσεις",
|
"noRecentAlerts": "Δεν υπάρχουν πρόσφατες ειδοποιήσεις",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Τίτλος ειδοποίησης",
|
"title": "Τίτλος ειδοποίησης",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Εισαγωγή Άδειες",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Εισαγωγή",
|
"import": "Εισαγωγή",
|
||||||
"exportTitle": "Export Άδειες",
|
"exportTitle": "Export Άδειες",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Προεπιλογές θεμάτων",
|
"presetsTitle": "Προεπιλογές θεμάτων",
|
||||||
"presetsSubtitle": "Επιλέξτε έναν προκατασκευασμένο συνδυασμό χρωμάτων",
|
"presetsSubtitle": "Επιλέξτε έναν προκατασκευασμένο συνδυασμό χρωμάτων",
|
||||||
"applyPreset": "Εφαρμόστε",
|
"applyPreset": "Εφαρμόστε",
|
||||||
"presetApplied": "Εφαρμόστηκε η προεπιλογή",
|
"presetApplied": "Εφαρμόστηκε η προεπιλογή: {preset}",
|
||||||
"customiseTitle": "Προσαρμοσμένα χρώματα",
|
"customiseTitle": "Προσαρμοσμένα χρώματα",
|
||||||
"primary": "Πρωτοβάθμια",
|
"primary": "Πρωτοβάθμια",
|
||||||
"secondary": "Δευτερεύουσα",
|
"secondary": "Δευτερεύουσα",
|
||||||
|
|||||||
+387
-36
@@ -878,7 +878,8 @@
|
|||||||
"codePlaceholder": "6-digit code",
|
"codePlaceholder": "6-digit code",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"enableIntro": "Add a second layer of security with an authenticator app.",
|
"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": {
|
"login": {
|
||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
@@ -1048,7 +1049,7 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"hubTools": "Tools",
|
"hubTools": "Tools",
|
||||||
"hubObservability": "Observability",
|
"hubObservability": "Observability",
|
||||||
"commandocentrum": "Commandocentrum",
|
"commandocentrum": "Command center",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
@@ -1094,7 +1095,15 @@
|
|||||||
"guilds": "Guilds",
|
"guilds": "Guilds",
|
||||||
"shop": "Shop",
|
"shop": "Shop",
|
||||||
"hint": "Start typing to search across users, articles, rooms, guilds and shop items"
|
"hint": "Start typing to search across users, articles, rooms, guilds and shop items"
|
||||||
}
|
},
|
||||||
|
"peopleSupport": "Users and support",
|
||||||
|
"catalogFurniture": "Catalog and furniture",
|
||||||
|
"communityContent": "Community and content",
|
||||||
|
"staffPermissions": "Staff and access",
|
||||||
|
"cmsErrors": "CMS errors",
|
||||||
|
"themeBuilder": "Theme builder",
|
||||||
|
"openSidebar": "Open menu",
|
||||||
|
"closeSidebar": "Close menu"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -1111,7 +1120,8 @@
|
|||||||
"rankWithCount": "Rank {rank}: {count} accounts",
|
"rankWithCount": "Rank {rank}: {count} accounts",
|
||||||
"recentStaffActivity": "Recent staff activity",
|
"recentStaffActivity": "Recent staff activity",
|
||||||
"viewAll": "View all",
|
"viewAll": "View all",
|
||||||
"noStaffActivityLogged": "No staff activity logged yet"
|
"noStaffActivityLogged": "No staff activity logged yet",
|
||||||
|
"totalAccounts": "{count} total accounts"
|
||||||
},
|
},
|
||||||
"articles": {
|
"articles": {
|
||||||
"title": "Articles",
|
"title": "Articles",
|
||||||
@@ -1151,8 +1161,26 @@
|
|||||||
"statusScheduled": "Scheduled",
|
"statusScheduled": "Scheduled",
|
||||||
"statusPublished": "Published",
|
"statusPublished": "Published",
|
||||||
"publishAt": "Publish at",
|
"publishAt": "Publish at",
|
||||||
"publishedAt": "Published at"
|
"publishedAt": "Published at",
|
||||||
}
|
"titleRequired": "Enter a title.",
|
||||||
|
"titleTooLong": "The title must be at most 255 characters.",
|
||||||
|
"summaryTooLong": "The summary must be at most 255 characters.",
|
||||||
|
"imageTooLong": "The image URL must be at most 255 characters.",
|
||||||
|
"invalidStatus": "Select a valid publication status.",
|
||||||
|
"publishDateRequired": "Choose a valid publication date.",
|
||||||
|
"saveFailedReference": "Could not save the article. Your content is preserved. Error reference: {reference}",
|
||||||
|
"articleNotFound": "Article not found.",
|
||||||
|
"summary": "Summary",
|
||||||
|
"imageNotFound": "Image not found. Enter a valid URL or browse.",
|
||||||
|
"imagePreview": "Article image preview",
|
||||||
|
"previewTitle": "Article preview",
|
||||||
|
"previewDescription": "Content preview. The published page applies the site theme.",
|
||||||
|
"previewFrame": "Article content preview"
|
||||||
|
},
|
||||||
|
"articlesShown": "Articles shown",
|
||||||
|
"latest": "Latest article",
|
||||||
|
"mostRecentPost": "Most recent publication",
|
||||||
|
"recentArticles": "Recent articles"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"newEvent": "+ New event",
|
"newEvent": "+ New event",
|
||||||
@@ -1252,7 +1280,8 @@
|
|||||||
},
|
},
|
||||||
"devops": {
|
"devops": {
|
||||||
"noEmulatorErrors": "No emulator errors found",
|
"noEmulatorErrors": "No emulator errors found",
|
||||||
"noRecentErrors": "No recent errors"
|
"noRecentErrors": "No recent errors",
|
||||||
|
"cmsErrors": "CMS error center"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"title": "Tags",
|
"title": "Tags",
|
||||||
@@ -1272,7 +1301,13 @@
|
|||||||
"colColour": "Colour",
|
"colColour": "Colour",
|
||||||
"colUsedBy": "Used by",
|
"colUsedBy": "Used by",
|
||||||
"colCreated": "Created",
|
"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": {
|
"navigation": {
|
||||||
"title": "Navigator",
|
"title": "Navigator",
|
||||||
@@ -1296,7 +1331,12 @@
|
|||||||
"colName": "Name",
|
"colName": "Name",
|
||||||
"colOrder": "Order",
|
"colOrder": "Order",
|
||||||
"colRooms": "Rooms",
|
"colRooms": "Rooms",
|
||||||
"colActions": "Actions"
|
"colActions": "Actions",
|
||||||
|
"publicFlatcats": "Public room categories",
|
||||||
|
"markedPublic": "Categories marked as public",
|
||||||
|
"visibleRooms": "Visible rooms",
|
||||||
|
"rows": "rows",
|
||||||
|
"unnamed": "Unnamed room"
|
||||||
},
|
},
|
||||||
"helpCenter": {
|
"helpCenter": {
|
||||||
"title": "Help Center",
|
"title": "Help Center",
|
||||||
@@ -1323,7 +1363,9 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"deleteEntry": "Delete entry",
|
"deleteEntry": "Delete entry",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
}
|
},
|
||||||
|
"withButton": "With a button",
|
||||||
|
"smallBoxes": "Small boxes"
|
||||||
},
|
},
|
||||||
"ads": {
|
"ads": {
|
||||||
"title": "Advertisements",
|
"title": "Advertisements",
|
||||||
@@ -1385,8 +1427,17 @@
|
|||||||
"save": "Save box",
|
"save": "Save box",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"deleteBox": "Delete box",
|
"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": {
|
"photos": {
|
||||||
"title": "Photo Moderation",
|
"title": "Photo Moderation",
|
||||||
@@ -1513,7 +1564,20 @@
|
|||||||
"save": "Save ban",
|
"save": "Save ban",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"ip": {
|
||||||
"title": "IP Management",
|
"title": "IP Management",
|
||||||
@@ -1533,7 +1597,11 @@
|
|||||||
"colIp": "IP",
|
"colIp": "IP",
|
||||||
"colReason": "Reason",
|
"colReason": "Reason",
|
||||||
"colAdded": "Added",
|
"colAdded": "Added",
|
||||||
"colActions": "Actions"
|
"colActions": "Actions",
|
||||||
|
"whitelisted": "Allowed IP addresses",
|
||||||
|
"blacklisted": "Blocked IP addresses",
|
||||||
|
"asnPlaceholder": "ASN (optional)",
|
||||||
|
"colAsn": "ASN"
|
||||||
},
|
},
|
||||||
"vpn": {
|
"vpn": {
|
||||||
"title": "VPN / Proxy Detection",
|
"title": "VPN / Proxy Detection",
|
||||||
@@ -1546,7 +1614,21 @@
|
|||||||
"blockOnDetectHint": "Automatically block users detected behind a VPN or proxy",
|
"blockOnDetectHint": "Automatically block users detected behind a VPN or proxy",
|
||||||
"save": "Save settings",
|
"save": "Save settings",
|
||||||
"saving": "Saving…",
|
"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": {
|
"tickets": {
|
||||||
"title": "CMS support desk",
|
"title": "CMS support desk",
|
||||||
@@ -1676,7 +1758,10 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"submitted": "Submitted",
|
"submitted": "Submitted",
|
||||||
"noMessage": "No message provided"
|
"noMessage": "No message provided"
|
||||||
}
|
},
|
||||||
|
"applicationsTitle": "Applications ({count})",
|
||||||
|
"userNumber": "User #{id}",
|
||||||
|
"rankLabel": "Rank {rank}"
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"title": "Teams",
|
"title": "Teams",
|
||||||
@@ -1704,7 +1789,26 @@
|
|||||||
"save": "Save team definition",
|
"save": "Save team definition",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"permissions": {
|
||||||
"title": "Permissions",
|
"title": "Permissions",
|
||||||
@@ -1879,8 +1983,23 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"save": "Save package",
|
"save": "Save package",
|
||||||
"saving": "Saving…",
|
"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": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2047,7 +2166,15 @@
|
|||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"statusActive": "Active",
|
"statusActive": "Active",
|
||||||
"statusCancelled": "Cancelled",
|
"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": {
|
"rareValues": {
|
||||||
"title": "Rare Values",
|
"title": "Rare Values",
|
||||||
@@ -2086,8 +2213,24 @@
|
|||||||
"pointTypeDiamonds": "Diamonds",
|
"pointTypeDiamonds": "Diamonds",
|
||||||
"save": "Save item",
|
"save": "Save item",
|
||||||
"saving": "Saving…",
|
"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": {
|
"badges": {
|
||||||
"title": "Badges",
|
"title": "Badges",
|
||||||
@@ -2118,7 +2261,10 @@
|
|||||||
"colImage": "Image",
|
"colImage": "Image",
|
||||||
"colName": "Name",
|
"colName": "Name",
|
||||||
"colDescription": "Description",
|
"colDescription": "Description",
|
||||||
"colActions": "Actions"
|
"colActions": "Actions",
|
||||||
|
"uploadedMessage": "Badge {code} uploaded.",
|
||||||
|
"errorMessage": "Upload failed: {error}",
|
||||||
|
"userIdPlaceholder": "User ID"
|
||||||
},
|
},
|
||||||
"achievements": {
|
"achievements": {
|
||||||
"title": "Achievements",
|
"title": "Achievements",
|
||||||
@@ -2132,7 +2278,14 @@
|
|||||||
"colProgress": "Progress",
|
"colProgress": "Progress",
|
||||||
"levelLabel": "Level {level}",
|
"levelLabel": "Level {level}",
|
||||||
"rewardCredits": "{count} credits",
|
"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": {
|
"calendar": {
|
||||||
"title": "Calendar campaigns",
|
"title": "Calendar campaigns",
|
||||||
@@ -2151,8 +2304,32 @@
|
|||||||
"days": "Days",
|
"days": "Days",
|
||||||
"dayLabel": "Day {day}",
|
"dayLabel": "Day {day}",
|
||||||
"reward": "Reward",
|
"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": {
|
"radio": {
|
||||||
"title": "Radio Moderation",
|
"title": "Radio Moderation",
|
||||||
@@ -2222,7 +2399,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -2346,7 +2523,8 @@
|
|||||||
"pointsPerMinuteHint": "Amount awarded for each full minute of listening.",
|
"pointsPerMinuteHint": "Amount awarded for each full minute of listening.",
|
||||||
"maxPerDayHint": "Daily cap per user. Use 0 for unlimited.",
|
"maxPerDayHint": "Daily cap per user. Use 0 for unlimited.",
|
||||||
"minListenersHint": "Points are only awarded when at least this many people are tuned in.",
|
"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": {
|
"autoDjForm": {
|
||||||
"title": "Radio AutoDJ",
|
"title": "Radio AutoDJ",
|
||||||
@@ -2399,7 +2577,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
@@ -2580,7 +2758,16 @@
|
|||||||
"valuePlaceholder": "Enter value…",
|
"valuePlaceholder": "Enter value…",
|
||||||
"save": "Save changes",
|
"save": "Save changes",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"alerts": {
|
||||||
"title": "Alerts",
|
"title": "Alerts",
|
||||||
@@ -2790,7 +2977,8 @@
|
|||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools"
|
"toolsGroup": "Tools",
|
||||||
|
"cmsErrors": "CMS errors"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2847,7 +3035,25 @@
|
|||||||
"save": "Save settings",
|
"save": "Save settings",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"confirmEnable": "Enabling maintenance will prevent all non-staff users from accessing the hotel. Continue?",
|
"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": {
|
"housekeeping": {
|
||||||
"title": "Permission archive",
|
"title": "Permission archive",
|
||||||
@@ -2931,7 +3137,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Import permissions",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"exportTitle": "Export permissions",
|
"exportTitle": "Export permissions",
|
||||||
@@ -3048,8 +3254,13 @@
|
|||||||
"variablesHint": "{username}, {email}, {hotel_name}",
|
"variablesHint": "{username}, {email}, {hotel_name}",
|
||||||
"save": "Save template",
|
"save": "Save template",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
}
|
"variablesPlaceholder": "Variables, separated by commas",
|
||||||
|
"active": "Active"
|
||||||
|
},
|
||||||
|
"create": "Create template",
|
||||||
|
"save": "Save template",
|
||||||
|
"updated": "Updated: {date}"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "Theme",
|
"title": "Theme",
|
||||||
@@ -3075,7 +3286,12 @@
|
|||||||
"saveCss": "Save CSS",
|
"saveCss": "Save CSS",
|
||||||
"savingCss": "Saving…",
|
"savingCss": "Saving…",
|
||||||
"reset": "Reset to defaults",
|
"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": {
|
"media": {
|
||||||
"title": "Media Library",
|
"title": "Media Library",
|
||||||
@@ -3160,6 +3376,43 @@
|
|||||||
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
|
"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.",
|
"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"
|
"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": {
|
"settings": {
|
||||||
@@ -3284,6 +3537,104 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"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…",
|
||||||
|
"users": "Users",
|
||||||
|
"articles": "Articles",
|
||||||
|
"rooms": "Rooms",
|
||||||
|
"guilds": "Groups",
|
||||||
|
"shop": "Shop",
|
||||||
|
"rareValues": "Rare values",
|
||||||
|
"noResults": "No results found."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"search": "Search…",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"filters": "Filters",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"all": "All",
|
||||||
|
"columns": "Columns",
|
||||||
|
"perPage": "{count} / page",
|
||||||
|
"selected": "{count} selected",
|
||||||
|
"results": "{count} results",
|
||||||
|
"empty": "No results match your search or filters.",
|
||||||
|
"selectAll": "Select all rows on this page",
|
||||||
|
"selectRow": "Select row {id}",
|
||||||
|
"removeFilter": "Remove filter: {name}",
|
||||||
|
"previous": "Previous page",
|
||||||
|
"next": "Next page",
|
||||||
|
"page": "Page {page}",
|
||||||
|
"pageOf": "Page {page} of {last}",
|
||||||
|
"bulkError": "The operation failed. Your selection has been kept; check the result before retrying.",
|
||||||
|
"confirmBulk": "{action} on {count} selected rows?"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"saved": "Saved successfully.",
|
||||||
|
"error": "The operation could not be completed.",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saveError": "Save failed. Your changes are still here; please try again.",
|
||||||
|
"unsaved": "Unsaved changes",
|
||||||
|
"leaveUnsaved": "You have unsaved changes. Leave this page?",
|
||||||
|
"preview": "Preview"
|
||||||
|
},
|
||||||
|
"workQueue": {
|
||||||
|
"title": "Work to review",
|
||||||
|
"tickets": "Open tickets",
|
||||||
|
"ticketsHint": "CMS and help-center queues",
|
||||||
|
"imports": "Imports to review",
|
||||||
|
"importsHint": "Your interrupted jobs or jobs with failed items",
|
||||||
|
"errors": "Open CMS error groups",
|
||||||
|
"errorsHint": "From the retained diagnostic history",
|
||||||
|
"unavailable": "Status unavailable — open to inspect"
|
||||||
|
},
|
||||||
|
"cmsErrors": {
|
||||||
|
"title": "CMS error center",
|
||||||
|
"storageError": "Error storage could not be read. Check the storage volume and permissions.",
|
||||||
|
"subtitle": "Server and browser errors from the last 7 days. Browser reports are unverified client observations.",
|
||||||
|
"emulator": "Emulator errors",
|
||||||
|
"refresh": "Refresh / clear filters",
|
||||||
|
"search": "Search errors",
|
||||||
|
"allSources": "All sources",
|
||||||
|
"empty": "No captured errors match these filters. Older console logs are not imported.",
|
||||||
|
"resolve": "Mark resolved",
|
||||||
|
"reopenHint": "A new occurrence reopens the group automatically.",
|
||||||
|
"references": "Occurrence references",
|
||||||
|
"stackHint": "Browser stacks may reference compiled chunks. Use the reference to investigate; request bodies are not stored.",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"searchHint": "Reference, digest, message, route or release",
|
||||||
|
"source": "Error source",
|
||||||
|
"pages": "Error pages",
|
||||||
|
"resolved": "Resolved",
|
||||||
|
"open": "Open",
|
||||||
|
"occurrences": "{count} occurrences",
|
||||||
|
"reference": "Reference",
|
||||||
|
"release": "Release",
|
||||||
|
"noStack": "No stack supplied. Use the reference or digest to correlate server logs.",
|
||||||
|
"truncated": "Showing the latest 1,000 events; filters search this retained window.",
|
||||||
|
"counts": "{count} occurrences · {groups} groups.",
|
||||||
|
"retention": "Storage is limited to 10 MB per day; excess events remain in server logs where available.",
|
||||||
|
"status": "Status",
|
||||||
|
"allStatuses": "All statuses"
|
||||||
|
},
|
||||||
|
"importHistory": {
|
||||||
|
"summary": "Import history · {count} active",
|
||||||
|
"empty": "No background imports yet.",
|
||||||
|
"result": "{done}/{total} imported · {failed} failed",
|
||||||
|
"progress": "Import progress",
|
||||||
|
"queued": "Queued",
|
||||||
|
"running": "Running",
|
||||||
|
"completed": "Completed",
|
||||||
|
"interrupted": "Interrupted",
|
||||||
|
"done": "Imported",
|
||||||
|
"failed": "Failed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"retryFailed": "Retry failed items only",
|
||||||
|
"retryUnfinished": "Retry unfinished items"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mod": {
|
"mod": {
|
||||||
|
|||||||
@@ -1679,7 +1679,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Actualizar"
|
"refresh": "Actualizar"
|
||||||
@@ -1738,7 +1738,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -1969,7 +1969,7 @@
|
|||||||
"title": "Alertas",
|
"title": "Alertas",
|
||||||
"subtitle": "Enviar alertas a todo el hotel y ver alertas recientes",
|
"subtitle": "Enviar alertas a todo el hotel y ver alertas recientes",
|
||||||
"sendAlert": "Enviar alerta al hotel",
|
"sendAlert": "Enviar alerta al hotel",
|
||||||
"recentAlerts": "Alertas recientes",
|
"recentAlerts": "Alertas recientes ({count})",
|
||||||
"noRecentAlerts": "No hay alertas recientes",
|
"noRecentAlerts": "No hay alertas recientes",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Título de la alerta",
|
"title": "Título de la alerta",
|
||||||
@@ -2180,7 +2180,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importar Permisos",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"exportTitle": "Export Permisos",
|
"exportTitle": "Export Permisos",
|
||||||
@@ -2317,7 +2317,7 @@
|
|||||||
"presetsTitle": "Preajustes de tema",
|
"presetsTitle": "Preajustes de tema",
|
||||||
"presetsSubtitle": "Elige un esquema de color predefinido",
|
"presetsSubtitle": "Elige un esquema de color predefinido",
|
||||||
"applyPreset": "Aplicar",
|
"applyPreset": "Aplicar",
|
||||||
"presetApplied": "Preajuste aplicado",
|
"presetApplied": "Preajuste aplicado: {preset}",
|
||||||
"customiseTitle": "Colores personalizados",
|
"customiseTitle": "Colores personalizados",
|
||||||
"primary": "Primario",
|
"primary": "Primario",
|
||||||
"secondary": "Secundario",
|
"secondary": "Secundario",
|
||||||
|
|||||||
@@ -1679,7 +1679,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Actualiser"
|
"refresh": "Actualiser"
|
||||||
@@ -1738,7 +1738,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -1969,7 +1969,7 @@
|
|||||||
"title": "Alertes",
|
"title": "Alertes",
|
||||||
"subtitle": "Envoyer des alertes à tout l'hôtel et voir les alertes récentes",
|
"subtitle": "Envoyer des alertes à tout l'hôtel et voir les alertes récentes",
|
||||||
"sendAlert": "Envoyer une alerte à l'hôtel",
|
"sendAlert": "Envoyer une alerte à l'hôtel",
|
||||||
"recentAlerts": "Alertes récentes",
|
"recentAlerts": "Alertes récentes ({count})",
|
||||||
"noRecentAlerts": "Aucune alerte récente",
|
"noRecentAlerts": "Aucune alerte récente",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titre de l'alerte",
|
"title": "Titre de l'alerte",
|
||||||
@@ -2180,7 +2180,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importer permissions",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
"exportTitle": "Export permissions",
|
"exportTitle": "Export permissions",
|
||||||
@@ -2317,7 +2317,7 @@
|
|||||||
"presetsTitle": "Préréglages de thème",
|
"presetsTitle": "Préréglages de thème",
|
||||||
"presetsSubtitle": "Choisir un jeu de couleurs prédéfini",
|
"presetsSubtitle": "Choisir un jeu de couleurs prédéfini",
|
||||||
"applyPreset": "Appliquer",
|
"applyPreset": "Appliquer",
|
||||||
"presetApplied": "Préréglage appliqué",
|
"presetApplied": "Préréglage appliqué: {preset}",
|
||||||
"customiseTitle": "Couleurs personnalisées",
|
"customiseTitle": "Couleurs personnalisées",
|
||||||
"primary": "Primaire",
|
"primary": "Primaire",
|
||||||
"secondary": "Secondaire",
|
"secondary": "Secondaire",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "nema postavljenog API-ja koji se sada igra",
|
"noNowPlayingHint": "nema postavljenog API-ja koji se sada igra",
|
||||||
"noListenersHint": "nije postavljen API slušatelja",
|
"noListenersHint": "nije postavljen API slušatelja",
|
||||||
"endpointDiagnostics": "Dijagnostika krajnje točke",
|
"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",
|
"streamUrl": "URL streama",
|
||||||
"unreachable": "nedostižan",
|
"unreachable": "nedostižan",
|
||||||
"refresh": "Osvježi"
|
"refresh": "Osvježi"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Nije postavljen radio_stream_url, tako da još nema ništa za ugraditi.",
|
"noStreamUrlText": "Nije postavljen radio_stream_url, tako da još nema ništa za ugraditi.",
|
||||||
"goToSettings": "Idite na Radio Settings",
|
"goToSettings": "Idite na Radio Settings",
|
||||||
"livePreview": "Pregled uživo",
|
"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",
|
"source": "Izvor",
|
||||||
"audioSnippet": "Isječak audio playera",
|
"audioSnippet": "Isječak audio playera",
|
||||||
"audioSnippetDesc": "Umetnuti HTML5 audio player usmjeren ravno prema streamu. Radi svugdje gdje je dopušten obični HTML.",
|
"audioSnippetDesc": "Umetnuti HTML5 audio player usmjeren ravno prema streamu. Radi svugdje gdje je dopušten obični HTML.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "upozorenja",
|
"title": "upozorenja",
|
||||||
"subtitle": "Šaljite upozorenja cijelom hotelu i pogledajte nedavna upozorenja",
|
"subtitle": "Šaljite upozorenja cijelom hotelu i pogledajte nedavna upozorenja",
|
||||||
"sendAlert": "Pošalji hotelsko upozorenje",
|
"sendAlert": "Pošalji hotelsko upozorenje",
|
||||||
"recentAlerts": "Nedavna upozorenja",
|
"recentAlerts": "Nedavna upozorenja ({count})",
|
||||||
"noRecentAlerts": "Nema nedavnih upozorenja",
|
"noRecentAlerts": "Nema nedavnih upozorenja",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Naslov upozorenja",
|
"title": "Naslov upozorenja",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Uvoz Dozvole",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "uvoz",
|
"import": "uvoz",
|
||||||
"exportTitle": "Export Dozvole",
|
"exportTitle": "Export Dozvole",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Unaprijed postavljene teme",
|
"presetsTitle": "Unaprijed postavljene teme",
|
||||||
"presetsSubtitle": "Odaberite unaprijed pripremljenu shemu boja",
|
"presetsSubtitle": "Odaberite unaprijed pripremljenu shemu boja",
|
||||||
"applyPreset": "primijeniti",
|
"applyPreset": "primijeniti",
|
||||||
"presetApplied": "Unaprijed postavljeno",
|
"presetApplied": "Unaprijed postavljeno: {preset}",
|
||||||
"customiseTitle": "Prilagođene boje",
|
"customiseTitle": "Prilagođene boje",
|
||||||
"primary": "Primarni",
|
"primary": "Primarni",
|
||||||
"secondary": "Sekundarna",
|
"secondary": "Sekundarna",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "nincs jelenleg lejátszott API-készlet",
|
"noNowPlayingHint": "nincs jelenleg lejátszott API-készlet",
|
||||||
"noListenersHint": "nincs hallgató API beállítva",
|
"noListenersHint": "nincs hallgató API beállítva",
|
||||||
"endpointDiagnostics": "Végpont diagnosztika",
|
"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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "elérhetetlen",
|
"unreachable": "elérhetetlen",
|
||||||
"refresh": "Frissítés"
|
"refresh": "Frissítés"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Nincs beállítva radio_stream_url, így még nincs mit beágyazni.",
|
"noStreamUrlText": "Nincs beállítva radio_stream_url, így még nincs mit beágyazni.",
|
||||||
"goToSettings": "Menjen a Rádióbeállításokhoz",
|
"goToSettings": "Menjen a Rádióbeállításokhoz",
|
||||||
"livePreview": "Élő előnézet",
|
"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",
|
"source": "Forrás",
|
||||||
"audioSnippet": "Hanglejátszó részlet",
|
"audioSnippet": "Hanglejátszó részlet",
|
||||||
"audioSnippetDesc": "A beugró HTML5 audiolejátszó egyenesen a streamre mutatott. Mindenhol működik, ahol a sima HTML megengedett.",
|
"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",
|
"title": "Figyelmeztetések",
|
||||||
"subtitle": "A szálloda egészére kiterjedő figyelmeztetések küldése és a legutóbbi figyelmeztetések megtekintése",
|
"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",
|
"sendAlert": "Szállodai figyelmeztetés küldése",
|
||||||
"recentAlerts": "Legutóbbi figyelmeztetések",
|
"recentAlerts": "Legutóbbi figyelmeztetések ({count})",
|
||||||
"noRecentAlerts": "Nincsenek friss figyelmeztetések",
|
"noRecentAlerts": "Nincsenek friss figyelmeztetések",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Figyelmeztetés címe",
|
"title": "Figyelmeztetés címe",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importálás Engedélyek",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importálás",
|
"import": "Importálás",
|
||||||
"exportTitle": "Export Engedélyek",
|
"exportTitle": "Export Engedélyek",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Előre beállított téma",
|
"presetsTitle": "Előre beállított téma",
|
||||||
"presetsSubtitle": "Válasszon egy előre elkészített színsémát",
|
"presetsSubtitle": "Válasszon egy előre elkészített színsémát",
|
||||||
"applyPreset": "Alkalmazni",
|
"applyPreset": "Alkalmazni",
|
||||||
"presetApplied": "Előbeállítás alkalmazva",
|
"presetApplied": "Előbeállítás alkalmazva: {preset}",
|
||||||
"customiseTitle": "Egyedi színek",
|
"customiseTitle": "Egyedi színek",
|
||||||
"primary": "Elsődleges",
|
"primary": "Elsődleges",
|
||||||
"secondary": "Másodlagos",
|
"secondary": "Másodlagos",
|
||||||
|
|||||||
+429
-40
@@ -848,7 +848,8 @@
|
|||||||
"codePlaceholder": "Codice a 6 cifre",
|
"codePlaceholder": "Codice a 6 cifre",
|
||||||
"confirm": "Conferma",
|
"confirm": "Conferma",
|
||||||
"enableIntro": "Aggiungi un livello di sicurezza in più con un'app di autenticazione.",
|
"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",
|
"title": "Account collegati",
|
||||||
"subtitle": "Collega un account social per poter accedere con un clic",
|
"subtitle": "Collega un account social per poter accedere con un clic",
|
||||||
@@ -906,7 +907,12 @@
|
|||||||
"alreadyHaveAccountPre": "Hai già un account?",
|
"alreadyHaveAccountPre": "Hai già un account?",
|
||||||
"alreadyHaveAccountLink": "Accedi",
|
"alreadyHaveAccountLink": "Accedi",
|
||||||
"whoIsOnline": "Chi è online",
|
"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": {
|
"forgot": {
|
||||||
"title": "Reimposta password",
|
"title": "Reimposta password",
|
||||||
@@ -1052,7 +1058,26 @@
|
|||||||
"tools": "Strumenti",
|
"tools": "Strumenti",
|
||||||
"guilds": "Gruppi",
|
"guilds": "Gruppi",
|
||||||
"itemsBase": "Items base",
|
"itemsBase": "Items base",
|
||||||
"studio": "Studio"
|
"studio": "Studio",
|
||||||
|
"peopleSupport": "Utenti e assistenza",
|
||||||
|
"catalogFurniture": "Catalogo e furni",
|
||||||
|
"communityContent": "Community e contenuti",
|
||||||
|
"staffPermissions": "Staff e accessi",
|
||||||
|
"cmsErrors": "Errori CMS",
|
||||||
|
"themeBuilder": "Editor del tema",
|
||||||
|
"catalogMaintenance": "Manutenzione catalogo",
|
||||||
|
"openSidebar": "Apri 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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -1069,7 +1094,8 @@
|
|||||||
"rankWithCount": "Grado {rank}: {count} account",
|
"rankWithCount": "Grado {rank}: {count} account",
|
||||||
"recentStaffActivity": "Attività recente dello staff",
|
"recentStaffActivity": "Attività recente dello staff",
|
||||||
"viewAll": "Vedi tutto",
|
"viewAll": "Vedi tutto",
|
||||||
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata"
|
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata",
|
||||||
|
"totalAccounts": "{count} account totali"
|
||||||
},
|
},
|
||||||
"articles": {
|
"articles": {
|
||||||
"title": "Articoli",
|
"title": "Articoli",
|
||||||
@@ -1103,8 +1129,32 @@
|
|||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"deleteArticle": "Elimina articolo",
|
"deleteArticle": "Elimina articolo",
|
||||||
"deleteConfirm": "Questa azione è irreversibile. L'articolo verrà rimosso permanentemente.",
|
"deleteConfirm": "Questa azione è irreversibile. L'articolo verrà rimosso permanentemente.",
|
||||||
"cancel": "Annulla"
|
"cancel": "Annulla",
|
||||||
}
|
"status": "Stato",
|
||||||
|
"statusDraft": "Bozza",
|
||||||
|
"statusScheduled": "Programmato",
|
||||||
|
"statusPublished": "Pubblicato",
|
||||||
|
"publishAt": "Data di pubblicazione",
|
||||||
|
"publishedAt": "Pubblicato il",
|
||||||
|
"titleRequired": "Inserisci un titolo.",
|
||||||
|
"titleTooLong": "Il titolo può contenere al massimo 255 caratteri.",
|
||||||
|
"summaryTooLong": "Il sommario può contenere al massimo 255 caratteri.",
|
||||||
|
"imageTooLong": "L’URL dell’immagine può contenere al massimo 255 caratteri.",
|
||||||
|
"invalidStatus": "Seleziona uno stato di pubblicazione valido.",
|
||||||
|
"publishDateRequired": "Scegli una data di pubblicazione valida.",
|
||||||
|
"saveFailedReference": "Impossibile salvare la news. Il contenuto è conservato. Riferimento errore: {reference}",
|
||||||
|
"articleNotFound": "News non trovata.",
|
||||||
|
"summary": "Sommario",
|
||||||
|
"imageNotFound": "Immagine non trovata. Inserisci un URL valido o sfoglia i media.",
|
||||||
|
"imagePreview": "Anteprima immagine della news",
|
||||||
|
"previewTitle": "Anteprima news",
|
||||||
|
"previewDescription": "Anteprima del contenuto. La pagina pubblicata usa il tema del sito.",
|
||||||
|
"previewFrame": "Anteprima contenuto della news"
|
||||||
|
},
|
||||||
|
"articlesShown": "Articoli mostrati",
|
||||||
|
"latest": "Ultimo articolo",
|
||||||
|
"mostRecentPost": "Pubblicazione più recente",
|
||||||
|
"recentArticles": "Articoli recenti"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"newEvent": "+ Nuovo evento",
|
"newEvent": "+ Nuovo evento",
|
||||||
@@ -1204,7 +1254,8 @@
|
|||||||
},
|
},
|
||||||
"devops": {
|
"devops": {
|
||||||
"noEmulatorErrors": "Nessun errore emulator trovato",
|
"noEmulatorErrors": "Nessun errore emulator trovato",
|
||||||
"noRecentErrors": "Nessun errore recente"
|
"noRecentErrors": "Nessun errore recente",
|
||||||
|
"cmsErrors": "Centro errori CMS"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"title": "Tag",
|
"title": "Tag",
|
||||||
@@ -1224,7 +1275,13 @@
|
|||||||
"colColour": "Colore",
|
"colColour": "Colore",
|
||||||
"colUsedBy": "Usato da",
|
"colUsedBy": "Usato da",
|
||||||
"colCreated": "Creato",
|
"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": {
|
"navigation": {
|
||||||
"title": "Navigatore",
|
"title": "Navigatore",
|
||||||
@@ -1248,7 +1305,12 @@
|
|||||||
"colName": "Nome",
|
"colName": "Nome",
|
||||||
"colOrder": "Ordine",
|
"colOrder": "Ordine",
|
||||||
"colRooms": "Stanze",
|
"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": {
|
"helpCenter": {
|
||||||
"title": "Centro assistenza",
|
"title": "Centro assistenza",
|
||||||
@@ -1275,7 +1337,9 @@
|
|||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"deleteEntry": "Elimina voce",
|
"deleteEntry": "Elimina voce",
|
||||||
"cancel": "Annulla"
|
"cancel": "Annulla"
|
||||||
}
|
},
|
||||||
|
"withButton": "Con pulsante",
|
||||||
|
"smallBoxes": "Riquadri piccoli"
|
||||||
},
|
},
|
||||||
"ads": {
|
"ads": {
|
||||||
"title": "Pubblicità",
|
"title": "Pubblicità",
|
||||||
@@ -1338,8 +1402,17 @@
|
|||||||
"save": "Salva box",
|
"save": "Salva box",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"deleteBox": "Elimina box",
|
"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": {
|
"photos": {
|
||||||
"title": "Moderazione foto",
|
"title": "Moderazione foto",
|
||||||
@@ -1466,7 +1539,20 @@
|
|||||||
"save": "Salva ban",
|
"save": "Salva ban",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"cancel": "Annulla"
|
"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": {
|
"ip": {
|
||||||
"title": "Gestione IP",
|
"title": "Gestione IP",
|
||||||
@@ -1486,7 +1572,11 @@
|
|||||||
"colIp": "IP",
|
"colIp": "IP",
|
||||||
"colReason": "Motivo",
|
"colReason": "Motivo",
|
||||||
"colAdded": "Aggiunto",
|
"colAdded": "Aggiunto",
|
||||||
"colActions": "Azioni"
|
"colActions": "Azioni",
|
||||||
|
"whitelisted": "Indirizzi IP consentiti",
|
||||||
|
"blacklisted": "Indirizzi IP bloccati",
|
||||||
|
"asnPlaceholder": "ASN (facoltativo)",
|
||||||
|
"colAsn": "ASN"
|
||||||
},
|
},
|
||||||
"vpn": {
|
"vpn": {
|
||||||
"title": "Rilevamento VPN / Proxy",
|
"title": "Rilevamento VPN / Proxy",
|
||||||
@@ -1499,7 +1589,21 @@
|
|||||||
"blockOnDetectHint": "Blocca automaticamente gli utenti rilevati dietro VPN o proxy",
|
"blockOnDetectHint": "Blocca automaticamente gli utenti rilevati dietro VPN o proxy",
|
||||||
"save": "Salva impostazioni",
|
"save": "Salva impostazioni",
|
||||||
"saving": "Salvataggio…",
|
"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": {
|
"tickets": {
|
||||||
"title": "Supporto CMS",
|
"title": "Supporto CMS",
|
||||||
@@ -1605,7 +1709,10 @@
|
|||||||
"status": "Stato",
|
"status": "Stato",
|
||||||
"submitted": "Inviata",
|
"submitted": "Inviata",
|
||||||
"noMessage": "Nessun messaggio fornito"
|
"noMessage": "Nessun messaggio fornito"
|
||||||
}
|
},
|
||||||
|
"applicationsTitle": "Candidature ({count})",
|
||||||
|
"userNumber": "Utente #{id}",
|
||||||
|
"rankLabel": "Grado {rank}"
|
||||||
},
|
},
|
||||||
"teams": {
|
"teams": {
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
@@ -1633,7 +1740,26 @@
|
|||||||
"save": "Salva definizione team",
|
"save": "Salva definizione team",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"cancel": "Annulla"
|
"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": {
|
"permissions": {
|
||||||
"title": "Permessi",
|
"title": "Permessi",
|
||||||
@@ -1776,8 +1902,23 @@
|
|||||||
"enabled": "Attivo",
|
"enabled": "Attivo",
|
||||||
"save": "Salva pacchetto",
|
"save": "Salva pacchetto",
|
||||||
"saving": "Salvataggio…",
|
"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": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -1821,7 +1962,20 @@
|
|||||||
"back": "Torna ai gruppi",
|
"back": "Torna ai gruppi",
|
||||||
"viewPublic": "Apri pagina pubblica",
|
"viewPublic": "Apri pagina pubblica",
|
||||||
"disband": "Sciogli gruppo",
|
"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": {
|
"transactions": {
|
||||||
"title": "Transazioni",
|
"title": "Transazioni",
|
||||||
@@ -1910,7 +2064,9 @@
|
|||||||
"save": "Salva buono",
|
"save": "Salva buono",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"cancel": "Annulla"
|
"cancel": "Annulla"
|
||||||
}
|
},
|
||||||
|
"updated": "Voucher aggiornato",
|
||||||
|
"updateError": "Impossibile aggiornare il voucher"
|
||||||
},
|
},
|
||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"title": "Abbonamenti",
|
"title": "Abbonamenti",
|
||||||
@@ -1929,7 +2085,15 @@
|
|||||||
"colActions": "Azioni",
|
"colActions": "Azioni",
|
||||||
"statusActive": "Attivo",
|
"statusActive": "Attivo",
|
||||||
"statusCancelled": "Annullato",
|
"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": {
|
"rareValues": {
|
||||||
"title": "Valori rari",
|
"title": "Valori rari",
|
||||||
@@ -1967,8 +2131,25 @@
|
|||||||
"pointTypeDiamonds": "Diamanti",
|
"pointTypeDiamonds": "Diamanti",
|
||||||
"save": "Salva articolo",
|
"save": "Salva articolo",
|
||||||
"saving": "Salvataggio…",
|
"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": {
|
"badges": {
|
||||||
"title": "Distintivi",
|
"title": "Distintivi",
|
||||||
@@ -1999,7 +2180,10 @@
|
|||||||
"colImage": "Immagine",
|
"colImage": "Immagine",
|
||||||
"colName": "Nome",
|
"colName": "Nome",
|
||||||
"colDescription": "Descrizione",
|
"colDescription": "Descrizione",
|
||||||
"colActions": "Azioni"
|
"colActions": "Azioni",
|
||||||
|
"uploadedMessage": "Distintivo {code} caricato.",
|
||||||
|
"errorMessage": "Caricamento non riuscito: {error}",
|
||||||
|
"userIdPlaceholder": "ID utente"
|
||||||
},
|
},
|
||||||
"achievements": {
|
"achievements": {
|
||||||
"title": "Trofei",
|
"title": "Trofei",
|
||||||
@@ -2013,7 +2197,14 @@
|
|||||||
"colProgress": "Progresso",
|
"colProgress": "Progresso",
|
||||||
"levelLabel": "Livello {level}",
|
"levelLabel": "Livello {level}",
|
||||||
"rewardCredits": "{count} crediti",
|
"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": {
|
"calendar": {
|
||||||
"title": "Campagne calendario",
|
"title": "Campagne calendario",
|
||||||
@@ -2032,8 +2223,32 @@
|
|||||||
"days": "Giorni",
|
"days": "Giorni",
|
||||||
"dayLabel": "Giorno {day}",
|
"dayLabel": "Giorno {day}",
|
||||||
"reward": "Ricompensa",
|
"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": {
|
"radio": {
|
||||||
"title": "Moderazione radio",
|
"title": "Moderazione radio",
|
||||||
@@ -2066,7 +2281,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Aggiorna"
|
"refresh": "Aggiorna"
|
||||||
@@ -2125,7 +2340,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -2232,7 +2447,7 @@
|
|||||||
"pointsForm": {
|
"pointsForm": {
|
||||||
"title": "Configurazione punti ascoltatore",
|
"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.",
|
"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",
|
"points": "Points",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -2462,13 +2677,22 @@
|
|||||||
"valuePlaceholder": "Inserisci valore…",
|
"valuePlaceholder": "Inserisci valore…",
|
||||||
"save": "Salva modifiche",
|
"save": "Salva modifiche",
|
||||||
"cancel": "Annulla"
|
"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": {
|
"alerts": {
|
||||||
"title": "Avvisi",
|
"title": "Avvisi",
|
||||||
"subtitle": "Invia avvisi a tutto l'hotel e visualizza avvisi recenti",
|
"subtitle": "Invia avvisi a tutto l'hotel e visualizza avvisi recenti",
|
||||||
"sendAlert": "Invia avviso hotel",
|
"sendAlert": "Invia avviso hotel",
|
||||||
"recentAlerts": "Avvisi recenti",
|
"recentAlerts": "Avvisi recenti ({count})",
|
||||||
"noRecentAlerts": "Nessun avviso recente",
|
"noRecentAlerts": "Nessun avviso recente",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titolo avviso",
|
"title": "Titolo avviso",
|
||||||
@@ -2603,7 +2827,25 @@
|
|||||||
"save": "Salva impostazioni",
|
"save": "Salva impostazioni",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"confirmEnable": "Attivare la manutenzione impedirà a tutti gli utenti non dello staff di accedere all'hotel. Continuare?",
|
"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": {
|
"housekeeping": {
|
||||||
"title": "Archivio permessi",
|
"title": "Archivio permessi",
|
||||||
@@ -2679,7 +2921,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Log di audit",
|
"tabAudit": "Log di audit",
|
||||||
"importPermissions": "Importa Permessi",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importa",
|
"import": "Importa",
|
||||||
"exportTitle": "Export Permessi",
|
"exportTitle": "Export Permessi",
|
||||||
@@ -2804,8 +3046,13 @@
|
|||||||
"variablesHint": "{username}, {email}, {hotel_name}",
|
"variablesHint": "{username}, {email}, {hotel_name}",
|
||||||
"save": "Salva template",
|
"save": "Salva template",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"cancel": "Annulla"
|
"cancel": "Annulla",
|
||||||
}
|
"variablesPlaceholder": "Variabili separate da virgole",
|
||||||
|
"active": "Attivo"
|
||||||
|
},
|
||||||
|
"create": "Crea modello",
|
||||||
|
"save": "Salva modello",
|
||||||
|
"updated": "Aggiornato: {date}"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "Tema",
|
"title": "Tema",
|
||||||
@@ -2816,7 +3063,7 @@
|
|||||||
"presetsTitle": "Preimpostazioni tema",
|
"presetsTitle": "Preimpostazioni tema",
|
||||||
"presetsSubtitle": "Scegli una combinazione di colori predefinita",
|
"presetsSubtitle": "Scegli una combinazione di colori predefinita",
|
||||||
"applyPreset": "Applica",
|
"applyPreset": "Applica",
|
||||||
"presetApplied": "Preimpostazione applicata",
|
"presetApplied": "Preset applicato: {preset}",
|
||||||
"customiseTitle": "Colori personalizzati",
|
"customiseTitle": "Colori personalizzati",
|
||||||
"primary": "Primario",
|
"primary": "Primario",
|
||||||
"secondary": "Secondario",
|
"secondary": "Secondario",
|
||||||
@@ -2832,7 +3079,11 @@
|
|||||||
"saveCss": "Salva CSS",
|
"saveCss": "Salva CSS",
|
||||||
"savingCss": "Salvataggio…",
|
"savingCss": "Salvataggio…",
|
||||||
"reset": "Ripristina valori predefiniti",
|
"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": {
|
"media": {
|
||||||
"title": "Libreria media",
|
"title": "Libreria media",
|
||||||
@@ -2965,7 +3216,10 @@
|
|||||||
"errors": "Errori",
|
"errors": "Errori",
|
||||||
"alerts": "Alert",
|
"alerts": "Alert",
|
||||||
"toolsGroup": "Strumenti",
|
"toolsGroup": "Strumenti",
|
||||||
"ticketInbox": "Inbox"
|
"ticketInbox": "Inbox",
|
||||||
|
"cmsErrors": "Errori CMS",
|
||||||
|
"themeBuilder": "Editor del tema",
|
||||||
|
"studio": "Studio"
|
||||||
},
|
},
|
||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
"eventsSubtitle": "Eventi hotel e tipi evento",
|
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||||
@@ -3088,6 +3342,43 @@
|
|||||||
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
|
"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.",
|
"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"
|
"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": {
|
"analytics": {
|
||||||
@@ -3221,6 +3512,104 @@
|
|||||||
"modNavInbox": "Inbox",
|
"modNavInbox": "Inbox",
|
||||||
"modNavDesk": "Supporto CMS",
|
"modNavDesk": "Supporto CMS",
|
||||||
"modNavHelp": "Help center"
|
"modNavHelp": "Help center"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"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…",
|
||||||
|
"users": "Utenti",
|
||||||
|
"articles": "Articoli",
|
||||||
|
"rooms": "Stanze",
|
||||||
|
"guilds": "Gruppi",
|
||||||
|
"shop": "Negozio",
|
||||||
|
"rareValues": "Valori dei rari",
|
||||||
|
"noResults": "Nessun risultato trovato."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"search": "Cerca…",
|
||||||
|
"clearSearch": "Cancella ricerca",
|
||||||
|
"filters": "Filtri",
|
||||||
|
"clearAll": "Rimuovi tutti",
|
||||||
|
"all": "Tutti",
|
||||||
|
"columns": "Colonne",
|
||||||
|
"perPage": "{count} / pagina",
|
||||||
|
"selected": "{count} selezionati",
|
||||||
|
"results": "{count} risultati",
|
||||||
|
"empty": "Nessun risultato corrisponde alla ricerca o ai filtri.",
|
||||||
|
"selectAll": "Seleziona tutte le righe di questa pagina",
|
||||||
|
"selectRow": "Seleziona riga {id}",
|
||||||
|
"removeFilter": "Rimuovi filtro: {name}",
|
||||||
|
"previous": "Pagina precedente",
|
||||||
|
"next": "Pagina successiva",
|
||||||
|
"page": "Pagina {page}",
|
||||||
|
"pageOf": "Pagina {page} di {last}",
|
||||||
|
"bulkError": "Operazione non riuscita. La selezione è stata mantenuta: verifica il risultato prima di riprovare.",
|
||||||
|
"confirmBulk": "Eseguire {action} su {count} righe selezionate?"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"saved": "Salvato correttamente.",
|
||||||
|
"error": "Impossibile completare l’operazione.",
|
||||||
|
"saving": "Salvataggio…",
|
||||||
|
"saveError": "Salvataggio non riuscito. Le modifiche sono ancora qui: riprova.",
|
||||||
|
"unsaved": "Modifiche non salvate",
|
||||||
|
"leaveUnsaved": "Hai modifiche non salvate. Vuoi lasciare la pagina?",
|
||||||
|
"preview": "Anteprima"
|
||||||
|
},
|
||||||
|
"workQueue": {
|
||||||
|
"title": "Da controllare",
|
||||||
|
"tickets": "Ticket aperti",
|
||||||
|
"ticketsHint": "Code del CMS e del centro assistenza",
|
||||||
|
"imports": "Import da verificare",
|
||||||
|
"importsHint": "I tuoi processi interrotti o con furni non importati",
|
||||||
|
"errors": "Gruppi di errori CMS aperti",
|
||||||
|
"errorsHint": "Nello storico diagnostico conservato",
|
||||||
|
"unavailable": "Stato non disponibile — apri per verificare"
|
||||||
|
},
|
||||||
|
"cmsErrors": {
|
||||||
|
"title": "Centro errori CMS",
|
||||||
|
"storageError": "Impossibile leggere gli errori. Verifica volume di archiviazione e permessi.",
|
||||||
|
"subtitle": "Errori server e browser degli ultimi 7 giorni. Le segnalazioni browser sono osservazioni del client non verificate.",
|
||||||
|
"emulator": "Errori emulatore",
|
||||||
|
"refresh": "Aggiorna / azzera filtri",
|
||||||
|
"search": "Cerca errori",
|
||||||
|
"allSources": "Tutte le origini",
|
||||||
|
"empty": "Nessun errore registrato corrisponde ai filtri. I vecchi log della console non vengono importati.",
|
||||||
|
"resolve": "Segna come risolto",
|
||||||
|
"reopenHint": "Una nuova occorrenza riapre automaticamente il gruppo.",
|
||||||
|
"references": "Riferimenti delle occorrenze",
|
||||||
|
"stackHint": "Gli stack del browser possono riferirsi a file compilati. Usa il riferimento per indagare; i contenuti delle richieste non vengono salvati.",
|
||||||
|
"previous": "Precedente",
|
||||||
|
"next": "Successiva",
|
||||||
|
"searchHint": "Riferimento, digest, messaggio, percorso o versione",
|
||||||
|
"source": "Origine errore",
|
||||||
|
"pages": "Pagine errori",
|
||||||
|
"resolved": "Risolto",
|
||||||
|
"open": "Aperto",
|
||||||
|
"occurrences": "{count} occorrenze",
|
||||||
|
"reference": "Riferimento",
|
||||||
|
"release": "Versione",
|
||||||
|
"noStack": "Stack non disponibile. Usa riferimento o digest per cercare nei log del server.",
|
||||||
|
"truncated": "Sono mostrati gli ultimi 1.000 eventi: i filtri cercano in questa finestra.",
|
||||||
|
"counts": "{count} occorrenze · {groups} gruppi.",
|
||||||
|
"retention": "Limite di archiviazione: 10 MB al giorno. Gli eventi eccedenti restano nei log server, dove disponibili.",
|
||||||
|
"status": "Stato",
|
||||||
|
"allStatuses": "Tutti gli stati"
|
||||||
|
},
|
||||||
|
"importHistory": {
|
||||||
|
"summary": "Storico importazioni · {count} attive",
|
||||||
|
"empty": "Nessuna importazione in background.",
|
||||||
|
"result": "{done}/{total} importati · {failed} falliti",
|
||||||
|
"progress": "Avanzamento importazione",
|
||||||
|
"queued": "In coda",
|
||||||
|
"running": "In corso",
|
||||||
|
"completed": "Completato",
|
||||||
|
"interrupted": "Interrotto",
|
||||||
|
"done": "Importato",
|
||||||
|
"failed": "Non riuscito",
|
||||||
|
"pending": "In attesa",
|
||||||
|
"retryFailed": "Riprova solo i furni falliti",
|
||||||
|
"retryUnfinished": "Riprendi i furni non completati"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radioRequests": {
|
"radioRequests": {
|
||||||
|
|||||||
+56
-5
@@ -1123,7 +1123,21 @@
|
|||||||
"saving": "Opslaan…",
|
"saving": "Opslaan…",
|
||||||
"deleteArticle": "Artikel verwijderen",
|
"deleteArticle": "Artikel verwijderen",
|
||||||
"deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.",
|
"deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.",
|
||||||
"cancel": "Annuleren"
|
"cancel": "Annuleren",
|
||||||
|
"titleRequired": "Voer een titel in.",
|
||||||
|
"titleTooLong": "De titel mag maximaal 255 tekens bevatten.",
|
||||||
|
"summaryTooLong": "De samenvatting mag maximaal 255 tekens bevatten.",
|
||||||
|
"imageTooLong": "De afbeeldings-URL mag maximaal 255 tekens bevatten.",
|
||||||
|
"invalidStatus": "Selecteer een geldige publicatiestatus.",
|
||||||
|
"publishDateRequired": "Kies een geldige publicatiedatum.",
|
||||||
|
"saveFailedReference": "Het artikel kon niet worden opgeslagen. Je inhoud blijft bewaard. Foutreferentie: {reference}",
|
||||||
|
"articleNotFound": "Artikel niet gevonden.",
|
||||||
|
"summary": "Samenvatting",
|
||||||
|
"imageNotFound": "Afbeelding niet gevonden. Voer een geldige URL in of blader door media.",
|
||||||
|
"imagePreview": "Voorbeeld van artikelafbeelding",
|
||||||
|
"previewTitle": "Artikelvoorbeeld",
|
||||||
|
"previewDescription": "Voorbeeld van de inhoud. De gepubliceerde pagina gebruikt het sitethema.",
|
||||||
|
"previewFrame": "Voorbeeld van artikelinhoud"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -2045,7 +2059,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -2222,7 +2236,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Vernieuwen"
|
"refresh": "Vernieuwen"
|
||||||
@@ -2297,7 +2311,7 @@
|
|||||||
"title": "Meldingen",
|
"title": "Meldingen",
|
||||||
"subtitle": "Hotelbrede meldingen verzenden en recente meldingen bekijken",
|
"subtitle": "Hotelbrede meldingen verzenden en recente meldingen bekijken",
|
||||||
"sendAlert": "Hotelmelding verzenden",
|
"sendAlert": "Hotelmelding verzenden",
|
||||||
"recentAlerts": "Recente meldingen",
|
"recentAlerts": "Recente meldingen ({count})",
|
||||||
"noRecentAlerts": "Geen recente meldingen",
|
"noRecentAlerts": "Geen recente meldingen",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Melding titel",
|
"title": "Melding titel",
|
||||||
@@ -2516,7 +2530,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Auditlog",
|
"tabAudit": "Auditlog",
|
||||||
"importPermissions": "Permissies importeren",
|
"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…",
|
"importPlaceholder": "Plak JSON hier…",
|
||||||
"import": "Importeren",
|
"import": "Importeren",
|
||||||
"exportTitle": "Permissies exporteren",
|
"exportTitle": "Permissies exporteren",
|
||||||
@@ -2774,6 +2788,43 @@
|
|||||||
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
|
"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.",
|
"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"
|
"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": {
|
"hubs": {
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "ingen API-sett som nå spilles",
|
"noNowPlayingHint": "ingen API-sett som nå spilles",
|
||||||
"noListenersHint": "ingen lyttere API satt",
|
"noListenersHint": "ingen lyttere API satt",
|
||||||
"endpointDiagnostics": "Endepunktdiagnostikk",
|
"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",
|
"streamUrl": "Strøm URL",
|
||||||
"unreachable": "uoppnåelig",
|
"unreachable": "uoppnåelig",
|
||||||
"refresh": "Oppdater"
|
"refresh": "Oppdater"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Det er ingen radio_stream_url satt, så det er ingenting å bygge inn ennå.",
|
"noStreamUrlText": "Det er ingen radio_stream_url satt, så det er ingenting å bygge inn ennå.",
|
||||||
"goToSettings": "Gå til Radioinnstillinger",
|
"goToSettings": "Gå til Radioinnstillinger",
|
||||||
"livePreview": "Live forhåndsvisning",
|
"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",
|
"source": "Kilde",
|
||||||
"audioSnippet": "Utdrag av lydspiller",
|
"audioSnippet": "Utdrag av lydspiller",
|
||||||
"audioSnippetDesc": "Drop-in HTML5-lydspiller pekte rett på strømmen. Fungerer overalt hvor vanlig HTML er tillatt.",
|
"audioSnippetDesc": "Drop-in HTML5-lydspiller pekte rett på strømmen. Fungerer overalt hvor vanlig HTML er tillatt.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Varsler",
|
"title": "Varsler",
|
||||||
"subtitle": "Send varsler for hele hotellet og se nylige varsler",
|
"subtitle": "Send varsler for hele hotellet og se nylige varsler",
|
||||||
"sendAlert": "Send hotellvarsel",
|
"sendAlert": "Send hotellvarsel",
|
||||||
"recentAlerts": "Nylige varsler",
|
"recentAlerts": "Nylige varsler ({count})",
|
||||||
"noRecentAlerts": "Ingen nylige varsler",
|
"noRecentAlerts": "Ingen nylige varsler",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Varseltittel",
|
"title": "Varseltittel",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importer Tillatelser",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
"exportTitle": "Export Tillatelser",
|
"exportTitle": "Export Tillatelser",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Forhåndsinnstillinger for tema",
|
"presetsTitle": "Forhåndsinnstillinger for tema",
|
||||||
"presetsSubtitle": "Velg et forhåndsbygd fargevalg",
|
"presetsSubtitle": "Velg et forhåndsbygd fargevalg",
|
||||||
"applyPreset": "Søk",
|
"applyPreset": "Søk",
|
||||||
"presetApplied": "Forhåndsinnstilling brukt",
|
"presetApplied": "Forhåndsinnstilling brukt: {preset}",
|
||||||
"customiseTitle": "Egendefinerte farger",
|
"customiseTitle": "Egendefinerte farger",
|
||||||
"primary": "Primær",
|
"primary": "Primær",
|
||||||
"secondary": "Sekundær",
|
"secondary": "Sekundær",
|
||||||
|
|||||||
@@ -1709,7 +1709,7 @@
|
|||||||
"noNowPlayingHint": "brak aktualnie odtwarzanego zestawu API",
|
"noNowPlayingHint": "brak aktualnie odtwarzanego zestawu API",
|
||||||
"noListenersHint": "brak zestawu API odbiorników",
|
"noListenersHint": "brak zestawu API odbiorników",
|
||||||
"endpointDiagnostics": "Diagnostyka punktów końcowych",
|
"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",
|
"streamUrl": "Adres URL strumienia",
|
||||||
"unreachable": "nieosiągalny",
|
"unreachable": "nieosiągalny",
|
||||||
"refresh": "Odśwież"
|
"refresh": "Odśwież"
|
||||||
@@ -1768,7 +1768,7 @@
|
|||||||
"noStreamUrlText": "Nie ma ustawionego radio_stream_url, więc nie ma jeszcze czego osadzać.",
|
"noStreamUrlText": "Nie ma ustawionego radio_stream_url, więc nie ma jeszcze czego osadzać.",
|
||||||
"goToSettings": "Przejdź do Ustawień radia",
|
"goToSettings": "Przejdź do Ustawień radia",
|
||||||
"livePreview": "Podgląd na żywo",
|
"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",
|
"source": "Źródło",
|
||||||
"audioSnippet": "Fragment odtwarzacza audio",
|
"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.",
|
"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",
|
"title": "Alerty",
|
||||||
"subtitle": "Wysyłaj alerty dotyczące całego hotelu i przeglądaj najnowsze alerty",
|
"subtitle": "Wysyłaj alerty dotyczące całego hotelu i przeglądaj najnowsze alerty",
|
||||||
"sendAlert": "Wyślij alert hotelowy",
|
"sendAlert": "Wyślij alert hotelowy",
|
||||||
"recentAlerts": "Ostatnie alerty",
|
"recentAlerts": "Ostatnie alerty ({count})",
|
||||||
"noRecentAlerts": "Brak ostatnich alertów",
|
"noRecentAlerts": "Brak ostatnich alertów",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Tytuł alertu",
|
"title": "Tytuł alertu",
|
||||||
@@ -2210,7 +2210,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importuj Uprawnienia",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importuj",
|
"import": "Importuj",
|
||||||
"exportTitle": "Export Uprawnienia",
|
"exportTitle": "Export Uprawnienia",
|
||||||
@@ -2347,7 +2347,7 @@
|
|||||||
"presetsTitle": "Wstępne ustawienia motywu",
|
"presetsTitle": "Wstępne ustawienia motywu",
|
||||||
"presetsSubtitle": "Wybierz gotowy schemat kolorów",
|
"presetsSubtitle": "Wybierz gotowy schemat kolorów",
|
||||||
"applyPreset": "Zastosuj",
|
"applyPreset": "Zastosuj",
|
||||||
"presetApplied": "Zastosowano ustawienie wstępne",
|
"presetApplied": "Zastosowano ustawienie wstępne: {preset}",
|
||||||
"customiseTitle": "Kolory niestandardowe",
|
"customiseTitle": "Kolory niestandardowe",
|
||||||
"primary": "Podstawowy",
|
"primary": "Podstawowy",
|
||||||
"secondary": "Drugorzędne",
|
"secondary": "Drugorzędne",
|
||||||
|
|||||||
@@ -1709,7 +1709,7 @@
|
|||||||
"noNowPlayingHint": "nenhum conjunto de API em execução",
|
"noNowPlayingHint": "nenhum conjunto de API em execução",
|
||||||
"noListenersHint": "nenhuma API de ouvinte definida",
|
"noListenersHint": "nenhuma API de ouvinte definida",
|
||||||
"endpointDiagnostics": "Diagnóstico de endpoint",
|
"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",
|
"streamUrl": "URL de transmissão",
|
||||||
"unreachable": "inacessível",
|
"unreachable": "inacessível",
|
||||||
"refresh": "Atualizar"
|
"refresh": "Atualizar"
|
||||||
@@ -1768,7 +1768,7 @@
|
|||||||
"noStreamUrlText": "Não há radio_stream_url definido, então não há nada para incorporar ainda.",
|
"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",
|
"goToSettings": "Vá para configurações de rádio",
|
||||||
"livePreview": "Visualização ao vivo",
|
"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",
|
"source": "Fonte",
|
||||||
"audioSnippet": "Snippet do player de áudio",
|
"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.",
|
"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",
|
"title": "Alertas",
|
||||||
"subtitle": "Envie alertas para todo o hotel e visualize alertas recentes",
|
"subtitle": "Envie alertas para todo o hotel e visualize alertas recentes",
|
||||||
"sendAlert": "Enviar alerta de hotel",
|
"sendAlert": "Enviar alerta de hotel",
|
||||||
"recentAlerts": "Alertas recentes",
|
"recentAlerts": "Alertas recentes ({count})",
|
||||||
"noRecentAlerts": "Nenhum alerta recente",
|
"noRecentAlerts": "Nenhum alerta recente",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Título do alerta",
|
"title": "Título do alerta",
|
||||||
@@ -2210,7 +2210,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importar Permissões",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"exportTitle": "Export Permissões",
|
"exportTitle": "Export Permissões",
|
||||||
@@ -2347,7 +2347,7 @@
|
|||||||
"presetsTitle": "Predefinições de tema",
|
"presetsTitle": "Predefinições de tema",
|
||||||
"presetsSubtitle": "Escolha um esquema de cores pré-construído",
|
"presetsSubtitle": "Escolha um esquema de cores pré-construído",
|
||||||
"applyPreset": "Aplicar",
|
"applyPreset": "Aplicar",
|
||||||
"presetApplied": "Predefinição aplicada",
|
"presetApplied": "Predefinição aplicada: {preset}",
|
||||||
"customiseTitle": "Cores personalizadas",
|
"customiseTitle": "Cores personalizadas",
|
||||||
"primary": "Primário",
|
"primary": "Primário",
|
||||||
"secondary": "Secundário",
|
"secondary": "Secundário",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "nici un set API care se joacă acum",
|
"noNowPlayingHint": "nici un set API care se joacă acum",
|
||||||
"noListenersHint": "nu se setează API pentru ascultători",
|
"noListenersHint": "nu se setează API pentru ascultători",
|
||||||
"endpointDiagnostics": "Diagnosticarea punctului final",
|
"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",
|
"streamUrl": "Adresa URL a fluxului",
|
||||||
"unreachable": "de neatins",
|
"unreachable": "de neatins",
|
||||||
"refresh": "Reîmprospătați"
|
"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.",
|
"noStreamUrlText": "Nu există nicio setare radio_stream_url, așa că încă nu există nimic de încorporat.",
|
||||||
"goToSettings": "Accesați Setările radio",
|
"goToSettings": "Accesați Setările radio",
|
||||||
"livePreview": "Previzualizare live",
|
"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",
|
"source": "Sursa",
|
||||||
"audioSnippet": "Fragment de player audio",
|
"audioSnippet": "Fragment de player audio",
|
||||||
"audioSnippetDesc": "Playerul audio HTML5 integrat a îndreptat direct spre flux. Funcționează oriunde este permis HTML simplu.",
|
"audioSnippetDesc": "Playerul audio HTML5 integrat a îndreptat direct spre flux. Funcționează oriunde este permis HTML simplu.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Alerte",
|
"title": "Alerte",
|
||||||
"subtitle": "Trimiteți alerte la nivel de hotel și vizualizați alertele recente",
|
"subtitle": "Trimiteți alerte la nivel de hotel și vizualizați alertele recente",
|
||||||
"sendAlert": "Trimiteți o alertă la hotel",
|
"sendAlert": "Trimiteți o alertă la hotel",
|
||||||
"recentAlerts": "Alerte recente",
|
"recentAlerts": "Alerte recente ({count})",
|
||||||
"noRecentAlerts": "Nu există alerte recente",
|
"noRecentAlerts": "Nu există alerte recente",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titlul alertei",
|
"title": "Titlul alertei",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Import Permisiuni",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"exportTitle": "Export Permisiuni",
|
"exportTitle": "Export Permisiuni",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "presetări teme",
|
"presetsTitle": "presetări teme",
|
||||||
"presetsSubtitle": "Alegeți o schemă de culori prefabricată",
|
"presetsSubtitle": "Alegeți o schemă de culori prefabricată",
|
||||||
"applyPreset": "Aplicați",
|
"applyPreset": "Aplicați",
|
||||||
"presetApplied": "Presetarea aplicată",
|
"presetApplied": "Presetarea aplicată: {preset}",
|
||||||
"customiseTitle": "Culori personalizate",
|
"customiseTitle": "Culori personalizate",
|
||||||
"primary": "Primar",
|
"primary": "Primar",
|
||||||
"secondary": "Secundar",
|
"secondary": "Secundar",
|
||||||
|
|||||||
@@ -1707,7 +1707,7 @@
|
|||||||
"noNowPlayingHint": "no now-playing API set",
|
"noNowPlayingHint": "no now-playing API set",
|
||||||
"noListenersHint": "no listeners API set",
|
"noListenersHint": "no listeners API set",
|
||||||
"endpointDiagnostics": "Endpoint diagnostics",
|
"endpointDiagnostics": "Endpoint diagnostics",
|
||||||
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <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",
|
"streamUrl": "Stream URL",
|
||||||
"unreachable": "unreachable",
|
"unreachable": "unreachable",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh"
|
||||||
@@ -1766,7 +1766,7 @@
|
|||||||
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
|
||||||
"goToSettings": "Go to Radio Settings",
|
"goToSettings": "Go to Radio Settings",
|
||||||
"livePreview": "Live preview",
|
"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",
|
"source": "Source",
|
||||||
"audioSnippet": "Audio player snippet",
|
"audioSnippet": "Audio player snippet",
|
||||||
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
|
||||||
@@ -1997,7 +1997,7 @@
|
|||||||
"title": "Alerts",
|
"title": "Alerts",
|
||||||
"subtitle": "Send hotel-wide alerts and view recent alerts",
|
"subtitle": "Send hotel-wide alerts and view recent alerts",
|
||||||
"sendAlert": "Send hotel alert",
|
"sendAlert": "Send hotel alert",
|
||||||
"recentAlerts": "Recent alerts",
|
"recentAlerts": "Recent alerts ({count})",
|
||||||
"noRecentAlerts": "No recent alerts",
|
"noRecentAlerts": "No recent alerts",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Alert title",
|
"title": "Alert title",
|
||||||
@@ -2208,7 +2208,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Импорт Разрешения",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
"exportTitle": "Export Разрешения",
|
"exportTitle": "Export Разрешения",
|
||||||
@@ -2345,7 +2345,7 @@
|
|||||||
"presetsTitle": "Theme presets",
|
"presetsTitle": "Theme presets",
|
||||||
"presetsSubtitle": "Choose a pre-built colour scheme",
|
"presetsSubtitle": "Choose a pre-built colour scheme",
|
||||||
"applyPreset": "Apply",
|
"applyPreset": "Apply",
|
||||||
"presetApplied": "Preset applied",
|
"presetApplied": "Preset applied: {preset}",
|
||||||
"customiseTitle": "Custom colours",
|
"customiseTitle": "Custom colours",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
"secondary": "Secondary",
|
"secondary": "Secondary",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "nie je teraz nastavené rozhranie API",
|
"noNowPlayingHint": "nie je teraz nastavené rozhranie API",
|
||||||
"noListenersHint": "nie je nastavené žiadne rozhranie API poslucháčov",
|
"noListenersHint": "nie je nastavené žiadne rozhranie API poslucháčov",
|
||||||
"endpointDiagnostics": "Diagnostika koncového bodu",
|
"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",
|
"streamUrl": "Webová adresa streamu",
|
||||||
"unreachable": "nedosiahnuteľný",
|
"unreachable": "nedosiahnuteľný",
|
||||||
"refresh": "Obnoviť"
|
"refresh": "Obnoviť"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Nie je nastavená žiadna adresa_url rádia, takže zatiaľ nie je čo vkladať.",
|
"noStreamUrlText": "Nie je nastavená žiadna adresa_url rádia, takže zatiaľ nie je čo vkladať.",
|
||||||
"goToSettings": "Prejdite do časti Nastavenia rádia",
|
"goToSettings": "Prejdite do časti Nastavenia rádia",
|
||||||
"livePreview": "Živá ukážka",
|
"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",
|
"source": "Zdroj",
|
||||||
"audioSnippet": "Útržok zvukového prehrávača",
|
"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.",
|
"audioSnippetDesc": "Zvukový prehrávač HTML5 nasmerovaný priamo na stream. Funguje všade, kde je povolený obyčajný HTML.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Upozornenia",
|
"title": "Upozornenia",
|
||||||
"subtitle": "Odosielajte upozornenia pre celý hotel a zobrazujte posledné upozornenia",
|
"subtitle": "Odosielajte upozornenia pre celý hotel a zobrazujte posledné upozornenia",
|
||||||
"sendAlert": "Odoslať upozornenie na hotel",
|
"sendAlert": "Odoslať upozornenie na hotel",
|
||||||
"recentAlerts": "Nedávne upozornenia",
|
"recentAlerts": "Nedávne upozornenia ({count})",
|
||||||
"noRecentAlerts": "Žiadne nedávne upozornenia",
|
"noRecentAlerts": "Žiadne nedávne upozornenia",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Názov upozornenia",
|
"title": "Názov upozornenia",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importovať Povolenia",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importovať",
|
"import": "Importovať",
|
||||||
"exportTitle": "Export Povolenia",
|
"exportTitle": "Export Povolenia",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Prednastavenia tém",
|
"presetsTitle": "Prednastavenia tém",
|
||||||
"presetsSubtitle": "Vyberte si vopred zostavenú farebnú schému",
|
"presetsSubtitle": "Vyberte si vopred zostavenú farebnú schému",
|
||||||
"applyPreset": "Použiť",
|
"applyPreset": "Použiť",
|
||||||
"presetApplied": "Predvoľba bola použitá",
|
"presetApplied": "Predvoľba bola použitá: {preset}",
|
||||||
"customiseTitle": "Vlastné farby",
|
"customiseTitle": "Vlastné farby",
|
||||||
"primary": "Primárne",
|
"primary": "Primárne",
|
||||||
"secondary": "Sekundárne",
|
"secondary": "Sekundárne",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "нема скупа АПИ-ја који се тренутно репродукује",
|
"noNowPlayingHint": "нема скупа АПИ-ја који се тренутно репродукује",
|
||||||
"noListenersHint": "није постављен АПИ слушалаца",
|
"noListenersHint": "није постављен АПИ слушалаца",
|
||||||
"endpointDiagnostics": "Ендпоинт диагностицс",
|
"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": "УРЛ стрима",
|
"streamUrl": "УРЛ стрима",
|
||||||
"unreachable": "недостижан",
|
"unreachable": "недостижан",
|
||||||
"refresh": "Освежи"
|
"refresh": "Освежи"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Радио_стреам_урл није постављен, тако да још нема шта да се угради.",
|
"noStreamUrlText": "Радио_стреам_урл није постављен, тако да још нема шта да се угради.",
|
||||||
"goToSettings": "Идите на Подешавања радија",
|
"goToSettings": "Идите на Подешавања радија",
|
||||||
"livePreview": "Преглед уживо",
|
"livePreview": "Преглед уживо",
|
||||||
"livePreviewText": "Ово је голи <audio> плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
|
"livePreviewText": "Ово је голи audio плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
|
||||||
"source": "Извор",
|
"source": "Извор",
|
||||||
"audioSnippet": "Исечак аудио плејера",
|
"audioSnippet": "Исечак аудио плејера",
|
||||||
"audioSnippetDesc": "Улазни ХТМЛ5 аудио плејер усмерен право на ток. Ради свуда где је обичан ХТМЛ дозвољен.",
|
"audioSnippetDesc": "Улазни ХТМЛ5 аудио плејер усмерен право на ток. Ради свуда где је обичан ХТМЛ дозвољен.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Алертс",
|
"title": "Алертс",
|
||||||
"subtitle": "Шаљите упозорења широм хотела и погледајте недавна упозорења",
|
"subtitle": "Шаљите упозорења широм хотела и погледајте недавна упозорења",
|
||||||
"sendAlert": "Пошаљите хотелско обавештење",
|
"sendAlert": "Пошаљите хотелско обавештење",
|
||||||
"recentAlerts": "Недавна упозорења",
|
"recentAlerts": "Недавна упозорења ({count})",
|
||||||
"noRecentAlerts": "Нема недавних упозорења",
|
"noRecentAlerts": "Нема недавних упозорења",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Наслов упозорења",
|
"title": "Наслов упозорења",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Увоз Дозволе",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Увоз",
|
"import": "Увоз",
|
||||||
"exportTitle": "Export Дозволе",
|
"exportTitle": "Export Дозволе",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Унапред подешене теме",
|
"presetsTitle": "Унапред подешене теме",
|
||||||
"presetsSubtitle": "Choose a pre-built colour scheme",
|
"presetsSubtitle": "Choose a pre-built colour scheme",
|
||||||
"applyPreset": "Пријавите се",
|
"applyPreset": "Пријавите се",
|
||||||
"presetApplied": "Пресет је примењен",
|
"presetApplied": "Пресет је примењен: {preset}",
|
||||||
"customiseTitle": "Прилагођене боје",
|
"customiseTitle": "Прилагођене боје",
|
||||||
"primary": "Примарни",
|
"primary": "Примарни",
|
||||||
"secondary": "Секундарни",
|
"secondary": "Секундарни",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "ingen nu-spelande API-uppsättning",
|
"noNowPlayingHint": "ingen nu-spelande API-uppsättning",
|
||||||
"noListenersHint": "ingen lyssnars API inställd",
|
"noListenersHint": "ingen lyssnars API inställd",
|
||||||
"endpointDiagnostics": "Slutpunktsdiagnostik",
|
"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",
|
"streamUrl": "Streama URL",
|
||||||
"unreachable": "oåtkomlig",
|
"unreachable": "oåtkomlig",
|
||||||
"refresh": "Uppdatera"
|
"refresh": "Uppdatera"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Det finns ingen radio_stream_url inställd, så det finns inget att bädda in ännu.",
|
"noStreamUrlText": "Det finns ingen radio_stream_url inställd, så det finns inget att bädda in ännu.",
|
||||||
"goToSettings": "Gå till Radioinställningar",
|
"goToSettings": "Gå till Radioinställningar",
|
||||||
"livePreview": "Live förhandsvisning",
|
"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",
|
"source": "Källa",
|
||||||
"audioSnippet": "Utdrag från ljudspelare",
|
"audioSnippet": "Utdrag från ljudspelare",
|
||||||
"audioSnippetDesc": "Drop-in HTML5-ljudspelare pekade rakt mot strömmen. Fungerar överallt där vanlig HTML är tillåtet.",
|
"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",
|
"title": "Varningar",
|
||||||
"subtitle": "Skicka varningar för hela hotellet och visa de senaste varningarna",
|
"subtitle": "Skicka varningar för hela hotellet och visa de senaste varningarna",
|
||||||
"sendAlert": "Skicka hotellvarning",
|
"sendAlert": "Skicka hotellvarning",
|
||||||
"recentAlerts": "Senaste varningar",
|
"recentAlerts": "Senaste varningar ({count})",
|
||||||
"noRecentAlerts": "Inga senaste varningar",
|
"noRecentAlerts": "Inga senaste varningar",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Varningsrubrik",
|
"title": "Varningsrubrik",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Importera Behörigheter",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Importera",
|
"import": "Importera",
|
||||||
"exportTitle": "Export Behörigheter",
|
"exportTitle": "Export Behörigheter",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Förinställda tema",
|
"presetsTitle": "Förinställda tema",
|
||||||
"presetsSubtitle": "Välj ett förbyggt färgschema",
|
"presetsSubtitle": "Välj ett förbyggt färgschema",
|
||||||
"applyPreset": "Ansök",
|
"applyPreset": "Ansök",
|
||||||
"presetApplied": "Förinställning tillämpas",
|
"presetApplied": "Förinställning tillämpas: {preset}",
|
||||||
"customiseTitle": "Anpassade färger",
|
"customiseTitle": "Anpassade färger",
|
||||||
"primary": "Primär",
|
"primary": "Primär",
|
||||||
"secondary": "Sekundär",
|
"secondary": "Sekundär",
|
||||||
|
|||||||
@@ -1709,7 +1709,7 @@
|
|||||||
"noNowPlayingHint": "şu anda oynatılan API seti yok",
|
"noNowPlayingHint": "şu anda oynatılan API seti yok",
|
||||||
"noListenersHint": "dinleyici API seti yok",
|
"noListenersHint": "dinleyici API seti yok",
|
||||||
"endpointDiagnostics": "Uç nokta teşhisi",
|
"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",
|
"streamUrl": "Akış URL'si",
|
||||||
"unreachable": "ulaşılamaz",
|
"unreachable": "ulaşılamaz",
|
||||||
"refresh": "Yenile"
|
"refresh": "Yenile"
|
||||||
@@ -1768,7 +1768,7 @@
|
|||||||
"noStreamUrlText": "Radio_stream_url kümesi yok, bu nedenle henüz eklenecek bir şey yok.",
|
"noStreamUrlText": "Radio_stream_url kümesi yok, bu nedenle henüz eklenecek bir şey yok.",
|
||||||
"goToSettings": "Radyo Ayarlarına Git",
|
"goToSettings": "Radyo Ayarlarına Git",
|
||||||
"livePreview": "Canlı önizleme",
|
"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",
|
"source": "Kaynak",
|
||||||
"audioSnippet": "Ses oynatıcı snippet'i",
|
"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.",
|
"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",
|
"title": "Uyarılar",
|
||||||
"subtitle": "Otel genelinde uyarılar gönderin ve son uyarıları görüntüleyin",
|
"subtitle": "Otel genelinde uyarılar gönderin ve son uyarıları görüntüleyin",
|
||||||
"sendAlert": "Otel uyarısı gönder",
|
"sendAlert": "Otel uyarısı gönder",
|
||||||
"recentAlerts": "Son uyarılar",
|
"recentAlerts": "Son uyarılar ({count})",
|
||||||
"noRecentAlerts": "Yeni uyarı yok",
|
"noRecentAlerts": "Yeni uyarı yok",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Uyarı başlığı",
|
"title": "Uyarı başlığı",
|
||||||
@@ -2210,7 +2210,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "İthalat İzinler",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "İthalat",
|
"import": "İthalat",
|
||||||
"exportTitle": "Export İzinler",
|
"exportTitle": "Export İzinler",
|
||||||
@@ -2347,7 +2347,7 @@
|
|||||||
"presetsTitle": "Tema ön ayarları",
|
"presetsTitle": "Tema ön ayarları",
|
||||||
"presetsSubtitle": "Önceden oluşturulmuş bir renk şeması seçin",
|
"presetsSubtitle": "Önceden oluşturulmuş bir renk şeması seçin",
|
||||||
"applyPreset": "Uygula",
|
"applyPreset": "Uygula",
|
||||||
"presetApplied": "Ön ayar uygulandı",
|
"presetApplied": "Ön ayar uygulandı: {preset}",
|
||||||
"customiseTitle": "Özel renkler",
|
"customiseTitle": "Özel renkler",
|
||||||
"primary": "Birincil",
|
"primary": "Birincil",
|
||||||
"secondary": "İkincil",
|
"secondary": "İkincil",
|
||||||
|
|||||||
@@ -1708,7 +1708,7 @@
|
|||||||
"noNowPlayingHint": "не налаштовано API поточного відтворення",
|
"noNowPlayingHint": "не налаштовано API поточного відтворення",
|
||||||
"noListenersHint": "API слухачів не встановлено",
|
"noListenersHint": "API слухачів не встановлено",
|
||||||
"endpointDiagnostics": "Діагностика кінцевої точки",
|
"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-адреса потоку",
|
"streamUrl": "URL-адреса потоку",
|
||||||
"unreachable": "недоступний",
|
"unreachable": "недоступний",
|
||||||
"refresh": "Оновити"
|
"refresh": "Оновити"
|
||||||
@@ -1767,7 +1767,7 @@
|
|||||||
"noStreamUrlText": "Немає параметра radio_stream_url, тому поки що немає чого вставляти.",
|
"noStreamUrlText": "Немає параметра radio_stream_url, тому поки що немає чого вставляти.",
|
||||||
"goToSettings": "Перейдіть до налаштувань радіо",
|
"goToSettings": "Перейдіть до налаштувань радіо",
|
||||||
"livePreview": "Попередній перегляд",
|
"livePreview": "Попередній перегляд",
|
||||||
"livePreviewText": "Це простий програвач <audio>, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
|
"livePreviewText": "Це простий програвач audio, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
|
||||||
"source": "Джерело",
|
"source": "Джерело",
|
||||||
"audioSnippet": "Фрагмент аудіоплеєра",
|
"audioSnippet": "Фрагмент аудіоплеєра",
|
||||||
"audioSnippetDesc": "Вставний аудіопрогравач HTML5 спрямований прямо на потік. Працює скрізь, де дозволено простий HTML.",
|
"audioSnippetDesc": "Вставний аудіопрогравач HTML5 спрямований прямо на потік. Працює скрізь, де дозволено простий HTML.",
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
"title": "Оповіщення",
|
"title": "Оповіщення",
|
||||||
"subtitle": "Надсилайте сповіщення для всього готелю та переглядайте останні сповіщення",
|
"subtitle": "Надсилайте сповіщення для всього готелю та переглядайте останні сповіщення",
|
||||||
"sendAlert": "Надіслати сповіщення готелю",
|
"sendAlert": "Надіслати сповіщення готелю",
|
||||||
"recentAlerts": "Останні сповіщення",
|
"recentAlerts": "Останні сповіщення ({count})",
|
||||||
"noRecentAlerts": "Немає останніх сповіщень",
|
"noRecentAlerts": "Немає останніх сповіщень",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Назва сповіщення",
|
"title": "Назва сповіщення",
|
||||||
@@ -2209,7 +2209,7 @@
|
|||||||
"tabTest": "Test",
|
"tabTest": "Test",
|
||||||
"tabAudit": "Audit log",
|
"tabAudit": "Audit log",
|
||||||
"importPermissions": "Імпорт Дозволи",
|
"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…",
|
"importPlaceholder": "Paste JSON here…",
|
||||||
"import": "Імпорт",
|
"import": "Імпорт",
|
||||||
"exportTitle": "Export Дозволи",
|
"exportTitle": "Export Дозволи",
|
||||||
@@ -2346,7 +2346,7 @@
|
|||||||
"presetsTitle": "Попередні налаштування теми",
|
"presetsTitle": "Попередні налаштування теми",
|
||||||
"presetsSubtitle": "Виберіть попередньо створену колірну схему",
|
"presetsSubtitle": "Виберіть попередньо створену колірну схему",
|
||||||
"applyPreset": "Застосувати",
|
"applyPreset": "Застосувати",
|
||||||
"presetApplied": "Попереднє налаштування застосовано",
|
"presetApplied": "Попереднє налаштування застосовано: {preset}",
|
||||||
"customiseTitle": "Нестандартні кольори",
|
"customiseTitle": "Нестандартні кольори",
|
||||||
"primary": "Первинний",
|
"primary": "Первинний",
|
||||||
"secondary": "Вторинний",
|
"secondary": "Вторинний",
|
||||||
|
|||||||
Reference in new issue
Block a user