Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
CI / check (push) Failing after 1m5s
CI / deploy (push) Skipped

This commit is contained in:
openhands committed 2026-09-06 19:21:47 +02:00
commit fea8023ac6
87 files changed
+3760 -1105

No files matched your search

+116
View File
@@ -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
View File
@@ -1,9 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/lib/admin/guard";
import {
ArticleInputError,
type ArticleSaveResult,
readArticleInput,
} from "@/lib/article-input";
import {
db,
WebsiteArticleComments,
@@ -12,152 +18,139 @@ import {
} from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { slugify } from "@/lib/format";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { invalidateNewsCache } from "@/lib/services/news-cache";
import { notify } from "@/lib/services/webhook";
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
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> {
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
for (;;) {
for (let n = 2; ; n++) {
const [existing] = await db
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
),
)
.limit(1);
if (!existing) return slug;
slug = `${base}-${n++}`;
const suffix = `-${n}`;
slug = base.slice(0, 255 - suffix.length) + suffix;
}
}
export async function createArticle(formData: FormData): Promise<void> {
async function saveFailure(
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 title = String(formData.get("title") ?? "")
.normalize("NFC")
.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.",
);
}
let input: ReturnType<typeof readArticleInput>;
let slug: string;
try {
input = readArticleInput(formData);
const { rawSlug, ...fields } = input;
slug = await uniqueSlug(rawSlug || fields.title);
const now = new Date();
const publishAt = parsePublishAt(rawPublishAt);
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
await db.insert(WebsiteArticles).values({
...fields,
slug,
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
status,
publishAt,
publishedAt: status === "published" ? now : null,
publishedAt: fields.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({
action: "news_publish",
actor: staff.username,
target: title,
target: input.title,
details: slug,
});
} catch {
// Database error — re-render unchanged with error.
redirect(
"/admin/articles/new?error=Database error while creating article. Please try again.",
);
}
redirect("/admin/articles");
await refreshNews();
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
export async function updateArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const t = await getTranslations("pages.admin.articles.form");
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const rawSlug = String(formData.get("slug") ?? "").trim();
const status = parseArticleStatus(formData.get("status"));
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
redirect(
"/admin/articles?error=Scheduled articles need a valid publish date.",
);
}
if (!id) return { ok: false, error: t("articleNotFound") };
let input: ReturnType<typeof readArticleInput>;
let becamePublished = false;
let slug: string;
try {
const publishAt = parsePublishAt(rawPublishAt);
input = readArticleInput(formData);
const [existing] = await db
.select({
slug: WebsiteArticles.slug,
status: WebsiteArticles.status,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
const publishedAt =
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
if (!existing) return { ok: false, error: t("articleNotFound") };
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
.update(WebsiteArticles)
.set({
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
shortStory: String(formData.get("shortStory") ?? "")
.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,
...fields,
slug,
publishedAt:
fields.status === "published"
? (existing.publishedAt ?? new Date())
: null,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
} catch {
redirect("/admin/articles?error=Update failed");
} catch (error) {
return saveFailure(error, "update");
}
if (becamePublished)
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
revalidatePath(`/admin/articles/${id}`);
redirect("/admin/articles");
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function deleteArticle(formData: FormData): Promise<void> {
@@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise<void> {
actor: staff.username,
target: article?.title ?? String(id),
});
} catch {
} catch (error) {
logger.error("News deletion failed", { module: "news", error });
redirect("/admin/articles?error=Delete failed");
}
await refreshNews();
redirect("/admin/articles");
}
+25 -38
View File
@@ -3,60 +3,47 @@
import fs from "node:fs/promises";
import path from "node:path";
import * as JSONC from "jsonc-parser";
import { revalidatePath } from "next/cache";
import { getTranslations } from "next-intl/server";
import { z } from "zod";
import { SUPPORTED_LOCALES } from "@/i18n/locales";
import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import {
CmsTranslationError,
saveCmsTranslation,
} from "@/lib/cms-translations";
import { patchJson5 } from "@/lib/json5-patch";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({
locale: z.enum([
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
]),
data: z.record(z.string(), z.unknown()),
locale: z.enum(SUPPORTED_LOCALES),
revision: z.string().regex(/^[a-f0-9]{64}$/),
changes: z
.record(z.string().max(300), z.string().max(20000).nullable())
.refine((value) => Object.keys(value).length <= 5000),
});
export const saveTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => {
const filePath = path.join(
process.cwd(),
"src",
"messages",
`${ctx.data.locale}.json`,
);
await fs.writeFile(
filePath,
JSON.stringify(ctx.data.data, null, 2),
"utf-8",
);
return actionOk();
try {
const snapshot = await saveCmsTranslation(
ctx.data.locale,
ctx.data.revision,
ctx.data.changes,
);
revalidatePath("/", "layout");
return actionOk({ snapshot });
} catch (error) {
if (!(error instanceof CmsTranslationError)) throw error;
const t = await getTranslations("pages.admin.translations.cms");
return { ok: false, error: t(error.code, { key: error.key }) };
}
},
);
+1 -7
View File
@@ -99,13 +99,7 @@ export default async function MePage({
);
const canClaim = referralTotal >= needed;
const remaining = Math.max(0, needed - referralTotal);
const onlineFriends = [
...new Map(
friends
.filter((friend) => friend.online === "1")
.map((friend) => [friend.id, friend]),
).values(),
];
const onlineFriends = friends.filter((friend) => friend.online === "1");
const date = (value: number) =>
value
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
+32 -35
View File
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { articleSlug } from "@/lib/article-input";
import { auth } from "@/lib/auth";
import { cachedQuery } from "@/lib/cached-db";
import {
db,
User,
@@ -20,6 +20,7 @@ import {
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { sanitize } from "@/lib/sanitize";
import { cacheNews } from "@/lib/services/news-cache";
type SearchParams = Promise<{
comment?: string;
@@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
params: Promise<{ slug: string[] }>;
}): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const { slug } = await params;
const slug = articleSlug((await params).slug);
const [article] = await db
.select({
title: WebsiteArticles.title,
@@ -95,44 +96,40 @@ export default async function ArticlePage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
params: Promise<{ slug: string[] }>;
searchParams: SearchParams;
}) {
const { slug } = await params;
const slug = articleSlug((await params).slug);
const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article");
const article = await cachedQuery(
`article:slug:${slug}`,
async () => {
const [row] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
const [row] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
)
.limit(1)
.catch(() => []);
return row ?? null;
},
60,
);
),
)
.limit(1)
.catch(() => []);
return row ?? null;
});
if (!article) notFound();
const articleId = article.id;
+5 -1
View File
@@ -31,6 +31,8 @@ export default async function EditArticle({
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
status: WebsiteArticles.status,
publishAt: WebsiteArticles.publishAt,
image: WebsiteArticles.image,
})
.from(WebsiteArticles)
@@ -63,7 +65,7 @@ export default async function EditArticle({
action={async (fd) => {
"use server";
fd.set("id", String(article.id));
await updateArticle(fd);
return updateArticle(fd);
}}
defaultValues={{
title: article.title,
@@ -71,6 +73,8 @@ export default async function EditArticle({
image: article.image,
shortStory: article.shortStory,
fullStory: article.fullStory,
status: article.status,
publishAt: article.publishAt?.toISOString(),
}}
/>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
+40 -41
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -11,6 +12,7 @@ export default async function CmsErrorsPage({
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin");
const t = await getTranslations("pages.admin.cmsErrors");
const params = await searchParams;
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
let result: Awaited<ReturnType<ErrorStore["read"]>>;
@@ -19,14 +21,15 @@ export default async function CmsErrorsPage({
} catch {
return (
<div className="admin-card" role="alert">
Error storage could not be read. Check the CMS storage volume and
permissions.
{t("storageError")}
</div>
);
}
const filtered = result.records.filter(
(r) =>
(!params.source || r.source === params.source) &&
(params.status !== "open" || !r.resolved) &&
(params.status !== "resolved" || r.resolved) &&
(!query || JSON.stringify(r).toLowerCase().includes(query)),
);
const groups = new Map<string, typeof filtered>();
@@ -43,62 +46,62 @@ export default async function CmsErrorsPage({
),
);
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 (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold">CMS error center</h1>
<p className="text-muted-foreground">
Server and browser errors from the last 7 days. Browser reports are
unverified client observations.
</p>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href="/admin/devops" className="btn btn-outline">
DevOps
</Link>
<Link href="/admin/devops/errors" className="btn btn-outline">
Emulator errors
{t("emulator")}
</Link>
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
Refresh / clear filters
{t("refresh")}
</Link>
</div>
<form className="admin-card flex flex-wrap gap-3">
<input
aria-label="Search errors"
aria-label={t("search")}
name="q"
defaultValue={params.q}
placeholder="Reference, digest, message, route or release"
placeholder={t("searchHint")}
className="input min-w-0 flex-1"
/>
<select
name="source"
aria-label="Error source"
aria-label={t("source")}
defaultValue={params.source ?? ""}
className="input"
>
<option value="">All sources</option>
<option value="">{t("allSources")}</option>
<option value="server">Server</option>
<option value="browser">Browser</option>
</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">
Search
{t("search")}
</button>
</form>
<p className="text-sm text-muted-foreground">
{filtered.length} occurrences · {groups.size} groups.{" "}
{result.truncated
? "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.
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
{result.truncated ? t("truncated") : ""} {t("retention")}
</p>
{groups.size === 0 ? (
<div className="admin-card">
No captured errors match these filters. Monitoring starts with this
deployment; older console logs are not imported.
</div>
<div className="admin-card">{t("empty")}</div>
) : null}
{[...groups.entries()]
.slice((page - 1) * 25, page * 25)
@@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
<details key={fingerprint} className="admin-card overflow-hidden">
<summary className="cursor-pointer">
<span className="text-xs text-muted-foreground">
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
{events.length} occurrences · {r.at}
{t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "}
{t("occurrences", { count: events.length })} · {r.at}
</span>
<p className="font-semibold break-words">{r.event}</p>
<p className="text-sm break-words">{r.message}</p>
</summary>
<div className="mt-4 space-y-3 text-sm">
<p>
Reference: <code className="break-all">{r.id}</code>
{t("reference")}: <code className="break-all">{r.id}</code>
</p>
<p>
Release: <code className="break-all">{r.release}</code>
{t("release")}: <code className="break-all">{r.release}</code>
</p>
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
{JSON.stringify(r.context, null, 2)}
</pre>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
{r.stack ||
"No stack supplied. Use the reference or digest to correlate server logs."}
{r.stack || t("noStack")}
</pre>
{!r.resolved &&
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
@@ -137,16 +139,16 @@ export default async function CmsErrorsPage({
value={r.fingerprint}
/>
<button type="submit" className="btn btn-outline">
Mark resolved
{t("resolve")}
</button>
<p className="text-muted-foreground text-xs">
A new occurrence reopens the group automatically.
{t("reopenHint")}
</p>
</form>
) : null}
<details>
<summary className="cursor-pointer">
Occurrence references
{t("references")}
</summary>
<ul>
{events.map((e) => (
@@ -156,23 +158,20 @@ export default async function CmsErrorsPage({
))}
</ul>
</details>
<p className="text-muted-foreground">
Browser stack traces may reference compiled chunks. This view
does not infer a root cause or include request bodies.
</p>
<p className="text-muted-foreground">{t("stackHint")}</p>
</div>
</details>
);
})}
<nav aria-label="Error pages" className="flex gap-3">
<nav aria-label={t("pages")} className="flex gap-3">
{page > 1 ? (
<Link href={pageHref(page - 1)} className="btn btn-outline">
Previous
{t("previous")}
</Link>
) : null}
{page * 25 < groups.size ? (
<Link href={pageHref(page + 1)} className="btn btn-outline">
Next
{t("next")}
</Link>
) : null}
</nav>
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function DevOpsPage() {
return (
<div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline">
CMS error center
{t("cmsErrors")}
</a>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
+1 -1
View File
@@ -18,7 +18,7 @@ export default function AdminErrorPage({
reset: () => void;
}) {
const t = useTranslations("pages.error");
const tNav = useTranslations("admin.nav");
const tNav = useTranslations("pages.admin.nav");
const [reference, setReference] = useState(error.digest);
useEffect(() => {
+5 -28
View File
@@ -11,14 +11,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { loadAdminNavigation } from "@/lib/admin/navigation";
import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
@@ -31,6 +26,7 @@ export default async function AdminLayout({
children: ReactNode;
}) {
const staff = await requireStaff();
const navigation = await loadAdminNavigation();
const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) {
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"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<AdminTopbar staff={staff} />
<AdminTopbar staff={staff} navigation={navigation} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
@@ -69,27 +65,8 @@ async function Sidebar({
}) {
const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase();
const { permissions } = await getAdminContext();
const isSuperAdmin = permissions.isSuperAdmin;
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, ""),
);
const { allowedPermissions, isSuperAdmin, navConfig } =
await loadAdminNavigation();
return (
<aside
+2
View File
@@ -8,6 +8,7 @@ import {
Users,
} from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AdminWorkQueue } from "@/components/admin/admin-work-queue";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { unixNow } from "@/lib/bans";
@@ -126,6 +127,7 @@ export default async function AdminDashboard() {
/>
</div>
<AdminWorkQueue />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5">
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
@@ -1,6 +1,7 @@
"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 { CatalogGitConfiguration } from "./catalog-git-configuration";
@@ -25,7 +26,10 @@ export function CatalogExportClient({
const [status, setStatus] = useState<ExportStatus | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try {
const response = await adminFetch("/api/admin/catalog-export", {
cache: "no-store",
@@ -35,15 +39,11 @@ export function CatalogExportClient({
setError("");
} catch {
setError("Unable to load export status");
} finally {
refreshing.current = false;
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15_000);
return () => clearInterval(timer);
}, [refresh]);
useVisiblePolling(refresh, 15000);
async function retry() {
setBusy(true);
try {
+19 -43
View File
@@ -1,48 +1,24 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getLocale } from "next-intl/server";
import { SUPPORTED_LOCALES } from "@/i18n/locales";
import { readCmsTranslation } from "@/lib/cms-translations";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { TranslationsClient } from "../translations-client";
const LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export default async function CmsTranslationsPage() {
const messagesDir = path.join(process.cwd(), "src", "messages");
const contents: Record<string, string> = {};
await Promise.all(
LOCALES.map(async (locale) => {
try {
contents[locale] = await fs.readFile(
path.join(messagesDir, `${locale}.json`),
"utf-8",
);
} catch {
// Skip missing locale files instead of failing the whole page.
}
}),
const [{ session, permissions }, locale, entries] = await Promise.all([
getAdminContext(),
getLocale(),
Promise.all(
SUPPORTED_LOCALES.map(
async (locale) => [locale, await readCmsTranslation(locale)] as const,
),
),
]);
return (
<TranslationsClient
snapshots={Object.fromEntries(entries)}
initialLocale={locale}
canEdit={canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)}
/>
);
return <TranslationsClient localeContents={contents} />;
}
+271 -169
View File
@@ -1,132 +1,127 @@
"use client";
import { Download, Save } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { saveTranslations } from "@/actions/translations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action";
import { flattenObject, unflattenObject } from "@/lib/translations-utils";
type Locale =
| "en"
| "it"
| "nl"
| "de"
| "fr"
| "es"
| "pt"
| "pl"
| "sv"
| "tr"
| "ro"
| "hu"
| "cs"
| "sk"
| "da"
| "no"
| "el"
| "bg"
| "hr"
| "sr"
| "uk"
| "ru";
const LOCALES: { code: Locale; label: string }[] = [
{ code: "en", label: "EN" },
{ code: "it", label: "IT" },
{ code: "nl", label: "NL" },
{ code: "de", label: "DE" },
{ code: "fr", label: "FR" },
{ code: "es", label: "ES" },
{ code: "pt", label: "PT" },
{ code: "pl", label: "PL" },
{ code: "sv", label: "SV" },
{ code: "tr", label: "TR" },
{ code: "ro", label: "RO" },
{ code: "hu", label: "HU" },
{ code: "cs", label: "CS" },
{ code: "sk", label: "SK" },
{ code: "da", label: "DA" },
{ code: "no", label: "NO" },
{ code: "el", label: "EL" },
{ code: "bg", label: "BG" },
{ code: "hr", label: "HR" },
{ code: "sr", label: "SR" },
{ code: "uk", label: "UK" },
{ code: "ru", label: "RU" },
];
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import {
type AppLocale,
isSupportedLocale,
SUPPORTED_LOCALES,
} from "@/i18n/locales";
import { validateCmsMessage } from "@/lib/cms-message-validation";
import type { CmsTranslationSnapshot } from "@/lib/cms-translations";
import { unflattenObject } from "@/lib/translations-utils";
interface Props {
localeContents: Record<string, string>;
snapshots: Record<string, CmsTranslationSnapshot>;
initialLocale: string;
canEdit: boolean;
}
export function TranslationsClient({ localeContents }: Props) {
const [data, setData] = useState<Record<string, Record<string, string>>>(
() => {
const initial: Record<string, Record<string, string>> = {};
for (const [locale, content] of Object.entries(localeContents)) {
initial[locale] = flattenObject(JSON.parse(content));
}
return initial;
},
const PER_PAGE = 30;
export function TranslationsClient({
snapshots,
initialLocale,
canEdit,
}: Props) {
const t = useTranslations("pages.admin.translations.cms");
const router = useRouter();
const [saved, setSaved] = useState(snapshots);
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>(
{},
);
const [activeLang, setActiveLang] = useState<AppLocale>(
isSupportedLocale(initialLocale) ? initialLocale : "en",
);
const [search, setSearch] = useState("");
const [group, setGroup] = useState("");
const [activeLang, setActiveLang] = useState<Locale>("en");
const { run, isPending } = useServerAction();
const PER_PAGE = 30;
const [status, setStatus] = useState("all");
const [page, setPage] = useState(1);
const activeData = data[activeLang];
const groups = useMemo(() => {
const g = new Set<string>();
Object.keys(data.en ?? {}).forEach((k) => {
const parts = k.split(".");
if (parts.length > 1) g.add(parts[0]);
});
return Array.from(g).sort();
}, [data]);
const filteredKeys = useMemo(() => {
if (!activeData) return [];
return Object.keys(activeData).filter((key) => {
const matchSearch =
!search ||
key.toLowerCase().includes(search.toLowerCase()) ||
activeData[key].toLowerCase().includes(search.toLowerCase());
const matchGroup = !group || key.startsWith(`${group}.`);
return matchSearch && matchGroup;
const { run, isPending } = useServerAction();
const source = saved.en.messages;
const activeData = saved[activeLang].messages;
const draft = drafts[activeLang] ?? {};
const keys = useMemo(() => Object.keys(source).sort(), [source]);
const groups = useMemo(
() => [...new Set(keys.map((key) => key.split(".")[0]))],
[keys],
);
const dirty = Object.values(drafts).some(
(values) => Object.keys(values).length > 0,
);
useUnsavedChanges(dirty, t("leaveWarning"));
const valueFor = (key: string) => draft[key] ?? activeData[key] ?? "";
const missing = keys.filter((key) => !valueFor(key).trim()).length;
const identical =
activeLang === "en"
? 0
: keys.filter((key) => valueFor(key) === source[key]).length;
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(draft)) {
const issue = validateCmsMessage(value, source[key]);
if (issue) errors[key] = t(issue, { key });
}
const filteredKeys = keys.filter((key) => {
const value = valueFor(key);
const matchesSearch = [key, value, source[key]].some((text) =>
text.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
);
const matchesStatus =
status === "all" ||
(status === "missing" && !activeData[key]?.trim()) ||
(status === "identical" &&
activeLang !== "en" &&
activeData[key] === source[key]) ||
(status === "modified" && Object.hasOwn(draft, key));
return (
matchesSearch && matchesStatus && (!group || key.startsWith(`${group}.`))
);
});
const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE));
const currentPage = Math.min(page, totalPages);
const pageKeys = filteredKeys.slice(
(currentPage - 1) * PER_PAGE,
currentPage * PER_PAGE,
);
function updateValue(key: string, value: string) {
setDrafts((current) => {
const next = { ...current[activeLang] };
if (value === (activeData[key] ?? "")) delete next[key];
else next[key] = value;
return { ...current, [activeLang]: next };
});
}, [activeData, search, group]);
const totalPages = Math.ceil(filteredKeys.length / PER_PAGE);
const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
function updateValue(key: string, value: string) {
setData({ ...data, [activeLang]: { ...activeData, [key]: value } });
}
function handleSave() {
if (!canEdit || !Object.keys(draft).length || Object.keys(errors).length)
return;
const locale = activeLang;
run(
() =>
saveTranslations({
locale: activeLang,
data: unflattenObject(activeData),
locale,
revision: saved[locale].revision,
changes: draft,
}),
{
successMessage: "Translations saved!",
successMessage: t("saved"),
onSuccess: (result) => {
const snapshot = result.snapshot as CmsTranslationSnapshot;
setSaved((current) => ({ ...current, [locale]: snapshot }));
setDrafts((current) => ({ ...current, [locale]: {} }));
router.refresh();
},
},
);
}
function handleExport() {
const blob = new Blob(
[JSON.stringify(unflattenObject(activeData), null, 2)],
{
type: "application/json",
},
[JSON.stringify(unflattenObject({ ...activeData, ...draft }), null, 2)],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -135,106 +130,213 @@ export function TranslationsClient({ localeContents }: Props) {
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<div className="flex justify-end">
<div className="flex gap-2">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">{t("title")}</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{t("description")}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="w-4 h-4 mr-1" /> Export
</Button>
<Button size="sm" onClick={handleSave} disabled={isPending}>
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
<Download className="mr-1 h-4 w-4" />
{t("export")}
</Button>
{canEdit && (
<Button
size="sm"
onClick={handleSave}
disabled={
isPending ||
!Object.keys(draft).length ||
!!Object.keys(errors).length
}
>
<Save className="mr-1 h-4 w-4" />
{isPending
? t("saving")
: t("saveLocale", { locale: activeLang.toUpperCase() })}
</Button>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-1 overflow-x-auto pb-1 max-w-full">
{LOCALES.map(({ code, label }) => (
{!canEdit && (
<p className="text-sm text-muted-foreground">{t("readOnly")}</p>
)}
<fieldset disabled={isPending} className="min-w-0 space-y-4">
<fieldset
className="flex min-w-0 flex-wrap gap-1"
aria-label={t("language")}
>
{SUPPORTED_LOCALES.map((code) => (
<Button
key={code}
variant={activeLang === code ? "default" : "outline"}
size="sm"
className="shrink-0"
variant={activeLang === code ? "default" : "outline"}
aria-pressed={activeLang === code}
onClick={() => {
setActiveLang(code);
setPage(1);
}}
>
{label}
{code.toUpperCase()}
{Object.keys(drafts[code] ?? {}).length ? " *" : ""}
</Button>
))}
</div>
<Input
placeholder="Filter by key or value..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="max-w-xs"
/>
<select
className="rounded-md border bg-background px-3 py-1 text-sm"
value={group}
onChange={(e) => {
setGroup(e.target.value);
setPage(1);
}}
>
<option value="">All groups</option>
{groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<span className="text-sm text-muted-foreground self-center">
{filteredKeys.length} keys
</span>
</div>
<div className="rounded-lg border bg-card divide-y">
{pageKeys.map((key) => (
<div key={key} className="flex items-center gap-3 px-4 py-2">
<span className="text-xs font-mono text-muted-foreground w-64 truncate flex-shrink-0">
{key}
</span>
</fieldset>
<p className="text-sm" role="status">
{t("coverage", { total: keys.length, missing, identical })}
</p>
<div className="flex flex-wrap items-end gap-3">
<label
htmlFor="cms-translation-search"
className="grid w-full min-w-0 gap-1 text-sm md:w-auto md:flex-1"
>
{t("search")}
<Input
value={activeData[key]}
onChange={(e) => updateValue(key, e.target.value)}
className="flex-1 h-8 text-sm"
id="cms-translation-search"
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t("searchPlaceholder")}
/>
</label>
<label className="grid gap-1 text-sm">
{t("group")}
<select
value={group}
onChange={(e) => {
setGroup(e.target.value);
setPage(1);
}}
className="rounded-md border bg-background px-3 py-2"
>
<option value="">{t("allGroups")}</option>
{groups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm">
{t("status")}
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
className="rounded-md border bg-background px-3 py-2"
>
<option value="all">{t("all")}</option>
<option value="missing">{t("missing")}</option>
{activeLang !== "en" && (
<option value="identical">{t("identical")}</option>
)}
<option value="modified">{t("modified")}</option>
</select>
</label>
</div>
<p className="text-xs text-muted-foreground">{t("fallbackHint")}</p>
{Object.keys(draft).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm">
{t("unsaved", { count: Object.keys(draft).length })}
</span>
<Button
size="sm"
variant="outline"
onClick={() => {
if (window.confirm(t("discardConfirm")))
setDrafts((current) => ({ ...current, [activeLang]: {} }));
}}
>
{t("discard")}
</Button>
{Object.keys(errors).length > 0 && (
<span role="alert" className="text-sm text-destructive">
{t("fixErrors", { count: Object.keys(errors).length })}
</span>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
)}
<div className="divide-y rounded-lg border bg-card">
{pageKeys.length === 0 && (
<p className="p-6 text-sm text-muted-foreground">
{t("noResults")}
</p>
)}
{pageKeys.map((key) => (
<div key={key} className="grid min-w-0 gap-2 p-4">
<label
htmlFor={`translation-${key}`}
className="break-all font-mono text-xs text-muted-foreground"
>
{key}
</label>
{activeLang !== "en" && (
<p className="whitespace-pre-wrap break-words text-sm">
<span className="font-medium">{t("source")}: </span>
{source[key]}
</p>
)}
<textarea
id={`translation-${key}`}
aria-invalid={!!errors[key]}
aria-describedby={errors[key] ? `error-${key}` : undefined}
readOnly={!canEdit}
value={valueFor(key)}
onChange={(e) => updateValue(key, e.target.value)}
placeholder={t("missingPlaceholder")}
rows={2}
className="w-full min-w-0 resize-y rounded-md border bg-background px-3 py-2 text-sm"
/>
{errors[key] && (
<p
id={`error-${key}`}
role="alert"
className="break-words text-sm text-destructive"
>
{errors[key]}
</p>
)}
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("page", {
page: currentPage,
total: totalPages,
count: filteredKeys.length,
})}
</span>
<div className="flex gap-1">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
disabled={currentPage <= 1}
onClick={() => setPage(currentPage - 1)}
>
Prev
{t("previous")}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
disabled={currentPage >= totalPages}
onClick={() => setPage(currentPage + 1)}
>
Next
{t("next")}
</Button>
</div>
</div>
)}
</fieldset>
</div>
);
}
+6 -4
View File
@@ -27,8 +27,10 @@ export default async function UserShowPage({
const t = await getTranslations("pages.admin.users.detail");
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id);
const sanctions = await loadUserSanctions(data.user.id);
const watched = await getWatchedUserIds(session.user.id);
const [sanctions, watched] = await Promise.all([
loadUserSanctions(data.user.id),
getWatchedUserIds(session.user.id),
]);
return (
<div className="space-y-6">
@@ -54,7 +56,7 @@ export default async function UserShowPage({
}
>
<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="badges">
{t("tabBadges", { count: data.user.badges.length })}
@@ -75,7 +77,7 @@ export default async function UserShowPage({
<TabsContent value="badges">
<div className="rounded-lg border bg-card p-4">
<h3 className="text-sm font-semibold mb-3">
Badges ({data.user.badges.length})
{t("tabBadges", { count: data.user.badges.length })}
</h3>
<UserBadgesReadonly badges={data.user.badges} />
</div>
+115 -86
View File
@@ -1,5 +1,6 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import {
@@ -11,7 +12,7 @@ import {
WebsiteRareValues,
WebsiteShopArticles,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { canAccess, PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = {
@@ -53,102 +54,130 @@ export const GET = withAdmin(
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 idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([
db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE),
canSearch("/admin/users")
? db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE),
canSearch("/admin/articles")
? db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE),
canSearch("/admin/rooms")
? db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE),
canSearch("/admin/guilds")
? db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
),
)
.limit(PER_TYPE),
canSearch("/admin/shop")
? db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
: []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE),
canSearch("/admin/rare-values")
? db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
]);
const groupMap: Record<
+7 -4
View File
@@ -1,7 +1,9 @@
import { and, eq, or, sql } from "drizzle-orm";
import { apiJson } from "@/lib/api";
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
@@ -14,9 +16,9 @@ export async function GET(
const { slug } = await params;
try {
const data = await redisCache(
const data = await cacheNews(
apiCacheKey(`articles:${slug}`),
60,
60_000,
async () => {
const [article] = await db
.select({
@@ -55,7 +57,8 @@ export async function GET(
}
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.
return apiJson({ error: "Article not found" }, { status: 200 });
}
+7 -4
View File
@@ -1,7 +1,9 @@
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api";
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.
@@ -13,9 +15,9 @@ export async function GET(req: Request) {
const { page, perPage, skip, take } = pagination(sp);
try {
const data = await redisCache(
const data = await cacheNews(
apiCacheKey(`articles:${page}:${perPage}`),
60,
60_000,
async () => {
const publishedFilter = and(
eq(WebsiteArticles.status, "published"),
@@ -60,7 +62,8 @@ export async function GET(req: Request) {
);
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.
return apiJson(
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
+6 -3
View File
@@ -3,7 +3,9 @@ import { env } from "@/env";
import { apiJson } from "@/lib/api";
import { db, User, WebsiteArticles } from "@/lib/db";
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
@@ -12,7 +14,7 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
*/
export async function GET(_req: Request) {
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([
db
.select({
@@ -45,7 +47,8 @@ export async function GET(_req: Request) {
});
});
return apiJson(data);
} catch {
} catch (error) {
logger.error("Public news query failed", { module: "news", error });
return apiJson(
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
{ status: 200 },
@@ -1,6 +1,7 @@
"use client";
import { Menu, X } from "lucide-react";
import { useTranslations } from "next-intl";
import {
createContext,
type ReactNode,
@@ -26,6 +27,7 @@ export function AdminMobileWrapper({
sidebar: ReactNode;
children: ReactNode;
}) {
const t = useTranslations("pages.admin.nav");
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
const mobile = useMemo(() => ({ close }), [close]);
@@ -57,6 +59,9 @@ export function AdminMobileWrapper({
<button
type="button"
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
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)" }}
@@ -67,7 +72,7 @@ export function AdminMobileWrapper({
className="text-sm font-bold"
style={{ color: "var(--admin-sidebar-text-readable)" }}
>
Admin Panel
{t("housekeeping")}
</span>
</div>
@@ -75,7 +80,7 @@ export function AdminMobileWrapper({
{open && (
<button
type="button"
aria-label="Close menu"
aria-label={t("closeSidebar")}
className="fixed inset-0 z-40 lg:hidden"
onClick={() => setOpen(false)}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
@@ -84,6 +89,7 @@ export function AdminMobileWrapper({
{/* Sidebar */}
<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 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
+7 -25
View File
@@ -11,15 +11,9 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import {
ADMIN_NAV_GROUPS,
type AdminNavItem,
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
import type { AdminNavItem } from "@/lib/admin-nav";
import type { AdminNavConfig } from "@/lib/admin-nav-config";
import { visibleAdminNavigation } from "@/lib/admin-navigation";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "admin-nav-collapsed";
@@ -83,24 +77,12 @@ export function AdminSidebarNav({
const [hydrated, setHydrated] = useState(false);
const [query, setQuery] = useState("");
const allowed = useMemo(
() => new Set(allowedPermissions),
[allowedPermissions],
const groups = useMemo(
() =>
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 visibleGroups = useMemo(
() =>
+5 -2
View File
@@ -6,11 +6,14 @@ import { SearchDialog } from "@/components/admin/search-dialog";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav";
import type { AdminNavigationOptions } from "@/lib/admin-navigation";
export function AdminTopbar({
staff,
navigation,
}: {
staff: { id: number; username: string; rank: number };
navigation: AdminNavigationOptions;
}) {
const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs");
@@ -19,7 +22,7 @@ export function AdminTopbar({
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
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">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title}
@@ -30,7 +33,7 @@ export function AdminTopbar({
</span>
</div>
<div className="flex items-center gap-3">
<SearchDialog />
<SearchDialog navigation={navigation} />
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
+104
View File
@@ -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>
);
}
+234 -159
View File
@@ -1,8 +1,10 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMemo, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleSaveResult } from "@/lib/article-input";
import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { MediaPicker } from "./media-picker";
@@ -12,7 +14,7 @@ export function ArticleForm({
action,
defaultValues,
}: {
action: (formData: FormData) => Promise<void>;
action: (formData: FormData) => Promise<ArticleSaveResult | void>;
edit?: boolean;
defaultValues?: {
title?: string;
@@ -25,12 +27,32 @@ export function ArticleForm({
};
}) {
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 [slug, setSlug] = useState(defaultValues?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
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 [preview, setPreview] = useState<ArticlePreviewData | null>(null);
@@ -39,175 +61,228 @@ export function ArticleForm({
return (
<form
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"
style={{ display: "grid", gap: "1rem" }}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("title")}
</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.
<fieldset disabled={pending} className="contents">
{saveError && (
<p role="alert" className="text-sm">
{saveError}
</p>
) : null}
</div>
<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" ? (
)}
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
{t("title")}
</span>
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
name="title"
maxLength={255}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
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") ?? ""),
});
}}
>
Preview
</button>
<ArticleSaveButton label={t("save")} />
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
maxLength={255}
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"
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>
);
}
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>
);
}
+5 -3
View File
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import {
Dialog,
DialogContent,
@@ -28,6 +29,7 @@ export function ArticlePreview({
data: ArticlePreviewData | null;
onClose: () => void;
}) {
const t = useTranslations("pages.admin.articles.form");
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>`
: "";
@@ -40,13 +42,13 @@ export function ArticlePreview({
>
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
<DialogHeader>
<DialogTitle>Article preview</DialogTitle>
<DialogTitle>{t("previewTitle")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
Content preview. The published page applies the site theme.
{t("previewDescription")}
</p>
<iframe
title="Article content preview"
title={t("previewFrame")}
sandbox=""
srcDoc={document}
className="min-h-0 flex-1 w-full bg-background rounded border"
+104 -53
View File
@@ -12,8 +12,10 @@ import {
SlidersHorizontal,
X,
} 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 { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -38,8 +40,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDebounce } from "@/hooks/use-debounce";
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
import { tablePageNumbers } from "@/lib/admin-table-state";
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 }>({
data,
columns,
searchPlaceholder = "Search...",
searchPlaceholder,
bulkActions,
onBulkAction,
exportUrl,
presets,
}: DataTableProps<T>) {
const t = useTranslations("pages.admin.table");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(
new Set(),
);
const [searchValue, setSearchValue] = useState(
searchParams.get("search") || "",
);
const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`;
const [selection, setSelection] = useState<{
scope: string;
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 currentSort = searchParams.get("sort") || "";
const currentOrder = searchParams.get("order") || "desc";
const debouncedSearch = useDebounce(searchValue, 300);
const isFirstRender = useRef(true);
// Collect active filters from URL
const filterColumns = columns.filter(
(col) => col.filterOptions && col.filterOptions.length > 0,
@@ -110,27 +130,31 @@ export function DataTable<T extends { id: number | string }>({
else params.delete(key);
}
startTransition(() => {
router.push(`?${params.toString()}`);
router.push(`?${params.toString()}`, { scroll: false });
});
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
updateParams({ search: debouncedSearch, page: "1" });
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
if (searchValue === urlSearch) return;
const timer = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (searchValue) params.set("search", searchValue);
else params.delete("search");
params.set("page", "1");
startTransition(() =>
router.replace(`?${params.toString()}`, { scroll: false }),
);
}, 300);
return () => clearTimeout(timer);
}, [searchValue, urlSearch, searchParams, router]);
function handleSearch(value: string) {
setSearchValue(value);
setDraft({ base: urlSearch, value });
}
function handleSort(key: string) {
const newOrder =
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
updateParams({ sort: key, order: newOrder });
updateParams({ sort: key, order: newOrder, page: "1" });
}
function handlePageChange(page: number) {
@@ -173,10 +197,33 @@ export function DataTable<T extends { id: number | string }>({
}
async function handleBulkAction(action: string) {
if (selectedIds.size === 0 || !onBulkAction) return;
await onBulkAction(action, Array.from(selectedIds));
setSelectedIds(new Set());
router.refresh();
if (
selectedIds.size === 0 ||
!onBulkAction ||
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) {
@@ -206,7 +253,7 @@ export function DataTable<T extends { id: number | string }>({
}
return (
<div className="space-y-4">
<div className="space-y-4 min-w-0" aria-busy={isPending || bulkBusy}>
{/* Preset filter chips */}
{presets && presets.length > 0 && (
<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">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
placeholder={searchPlaceholder ?? t("search")}
aria-label={searchPlaceholder ?? t("search")}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
style={{ paddingLeft: 36 }}
className="pl-9"
/>
</div>
@@ -249,7 +298,7 @@ export function DataTable<T extends { id: number | string }>({
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
Filters
{t("filters")}
{activeFilterCount > 0 && (
<Badge
variant="secondary"
@@ -263,7 +312,7 @@ export function DataTable<T extends { id: number | string }>({
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<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 && (
<Button
variant="ghost"
@@ -271,7 +320,7 @@ export function DataTable<T extends { id: number | string }>({
className="h-7 px-2 text-xs"
onClick={clearAllFilters}
>
Clear all
{t("clearAll")}
</Button>
)}
</div>
@@ -291,7 +340,7 @@ export function DataTable<T extends { id: number | string }>({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
<SelectItem value="__all__">{t("all")}</SelectItem>
{col.filterOptions?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
@@ -320,6 +369,7 @@ export function DataTable<T extends { id: number | string }>({
{col.label}: {opt?.label || val}
<button
type="button"
aria-label={t("removeFilter", { name: col.label })}
onClick={() => handleFilterChange(col, "__all__")}
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>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Columns
{t("columns")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<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) => (
<div
key={col.key}
@@ -349,6 +399,7 @@ export function DataTable<T extends { id: number | string }>({
>
<Checkbox
checked={!hiddenColumns.has(col.key)}
aria-label={col.label}
onCheckedChange={(checked) => {
setHiddenColumns((prev) => {
const next = new Set(prev);
@@ -375,7 +426,7 @@ export function DataTable<T extends { id: number | string }>({
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / page
{t("perPage", { count: n })}
</SelectItem>
))}
</SelectContent>
@@ -393,13 +444,14 @@ export function DataTable<T extends { id: number | string }>({
{bulkActions && selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{selectedIds.size} selected
{t("selected", { count: selectedIds.size })}
</span>
{bulkActions.map((action) => (
<Button
key={action.value}
variant={action.variant || "default"}
size="sm"
disabled={bulkBusy || isPending}
onClick={() => handleBulkAction(action.value)}
>
{action.label}
@@ -409,7 +461,7 @@ export function DataTable<T extends { id: number | string }>({
)}
<div className="ml-auto text-sm text-muted-foreground">
{data.total} results
{t("results", { count: data.total })}
</div>
</div>
@@ -425,6 +477,8 @@ export function DataTable<T extends { id: number | string }>({
selectedIds.size === data.rows.length &&
data.rows.length > 0
}
aria-label={t("selectAll")}
disabled={isPending || bulkBusy}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
@@ -459,7 +513,7 @@ export function DataTable<T extends { id: number | string }>({
}
className="h-24 text-center text-muted-foreground"
>
No results found.
{t("empty")}
</TableCell>
</TableRow>
) : (
@@ -473,6 +527,8 @@ export function DataTable<T extends { id: number | string }>({
<TableCell>
<Checkbox
checked={selectedIds.has(row.id)}
aria-label={t("selectRow", { id: String(row.id) })}
disabled={isPending || bulkBusy}
onCheckedChange={() => toggleSelect(row.id)}
/>
</TableCell>
@@ -504,31 +560,25 @@ export function DataTable<T extends { id: number | string }>({
{data.lastPage > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {data.page} of {data.lastPage}
{t("pageOf", { page: data.page, last: data.lastPage })}
</p>
<div className="flex items-center gap-1 flex-wrap justify-end">
<Button
variant="outline"
size="icon"
disabled={data.page <= 1}
aria-label={t("previous")}
disabled={data.page <= 1 || isPending}
onClick={() => handlePageChange(data.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => {
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;
}
{tablePageNumbers(data.page, data.lastPage).map((pageNum) => {
return (
<Button
key={pageNum}
aria-label={t("page", { page: pageNum })}
aria-current={pageNum === data.page ? "page" : undefined}
disabled={isPending}
variant={pageNum === data.page ? "default" : "outline"}
size="icon"
onClick={() => handlePageChange(pageNum)}
@@ -540,7 +590,8 @@ export function DataTable<T extends { id: number | string }>({
<Button
variant="outline"
size="icon"
disabled={data.page >= data.lastPage}
aria-label={t("next")}
disabled={data.page >= data.lastPage || isPending}
onClick={() => handlePageChange(data.page + 1)}
>
<ChevronRight className="h-4 w-4" />
+13 -4
View File
@@ -57,13 +57,17 @@ export function RichText({
defaultValue = "",
placeholder,
rows = 10,
onChange,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
rows?: number;
onChange?: () => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const changeCallback = useRef(onChange);
changeCallback.current = onChange;
const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
@@ -94,11 +98,16 @@ export function RichText({
on: (ev: string, cb: () => void) => void;
getContent: () => string;
}) => {
const sync = () => {
if (textareaRef.current)
textareaRef.current.value = editor.getContent();
const sync = (notify: boolean) => {
const field = textareaRef.current;
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(() => {
+72 -8
View File
@@ -13,8 +13,17 @@ import {
CommandItem,
CommandList,
} 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 {
type AdminNavigationOptions,
visibleAdminNavigation,
} from "@/lib/admin-navigation";
type SearchResult = AdminSearchResult;
@@ -23,13 +32,30 @@ type SearchResponse = {
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 router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const debouncedQuery = useDebounce(query, 250);
const abortRef = useRef<AbortController | null>(null);
@@ -44,6 +70,8 @@ export function SearchDialog() {
const fetchResults = useCallback(async (q: string) => {
abortRef.current?.abort();
setFailed(false);
setResults([]);
if (q.length < 2) {
setResults([]);
setLoading(false);
@@ -56,21 +84,27 @@ export function SearchDialog() {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
if (!res.ok) return;
if (!res.ok) throw new Error("Search request failed");
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);
else setFailed(true);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
if (abortRef.current === controller) setResults([]);
if (abortRef.current === controller) {
setResults([]);
setFailed(true);
}
} finally {
if (abortRef.current === controller) setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
fetchResults(debouncedQuery);
}, [debouncedQuery, fetchResults]);
return () => abortRef.current?.abort();
}, [debouncedQuery, fetchResults, open]);
useEffect(() => {
return () => abortRef.current?.abort();
@@ -94,13 +128,15 @@ export function SearchDialog() {
router.push(url);
}
const grouped = groupResults(results);
const grouped = groupResults(query === debouncedQuery ? results : []);
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
return (
<Dialog open={open} onOpenChange={setOpen}>
<button
type="button"
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"
>
<SearchIcon size={14} />
@@ -110,6 +146,10 @@ export function SearchDialog() {
</kbd>
</button>
<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
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
shouldFilter={false}
@@ -126,7 +166,31 @@ export function SearchDialog() {
…
</div>
) : 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]) => (
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
{items.map((item) => (
+8 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
type SearchInputProps = {
@@ -15,21 +17,24 @@ export function SearchInput({
placeholder,
onClear,
}: SearchInputProps) {
const t = useTranslations("pages.admin.table");
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={placeholder}
aria-label={placeholder ?? t("search")}
placeholder={placeholder ?? t("search")}
value={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 && (
<button
type="button"
onClick={onClear}
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" />
</button>
+35 -15
View File
@@ -1,26 +1,29 @@
"use client";
import { useFormatter, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
}: {
state: ReturnType<typeof useFurnitureJobs>;
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer">
Import history ·{" "}
{
state.jobs.filter(
(j) => j.state === "queued" || j.state === "running",
).length
}{" "}
active
{t("summary", {
count: state.jobs.filter(
(job) => job.state === "queued" || job.state === "running",
).length,
})}
</summary>
<div className="max-h-72 space-y-2 overflow-auto py-2">
{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) => {
const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"),
failed = job.items.filter((i) => i.state === "failed");
return (
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
className="rounded border border-[var(--admin-border)] p-2"
>
<summary className="cursor-pointer">
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
{done.length}/{job.items.length} imported · {failed.length}{" "}
failed
{format.dateTime(new Date(job.createdAt), {
dateStyle: "short",
timeStyle: "short",
})}{" "}
· {t(job.state)} ·{" "}
{t("result", {
done: done.length,
total: job.items.length,
failed: failed.length,
})}
</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">
{job.items.map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {item.state}
<strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""}
{item.error && (
<p className="text-[var(--admin-warning)]">
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
</li>
))}
</ul>
{failed.length > 0 && job.state !== "running" && (
{retryItems.length > 0 && (
<Button
variant="outline"
disabled={state.busy}
onClick={() =>
void state.submit(failed, {
void state.submit(retryItems, {
sourceId: job.sourceId,
mode: job.mode,
translate: job.translate,
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
})
}
>
Retry failed items only
{t(
job.state === "interrupted"
? "retryUnfinished"
: "retryFailed",
)}
</Button>
)}
</details>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) {
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null);
const submitting = useRef(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try {
const response = await adminFetch("/api/admin/studio/import-jobs");
const data = await response.json();
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
setError(
error instanceof Error ? error.message : "Cannot load import history",
);
} finally {
refreshing.current = false;
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => clearInterval(timer);
}, [refresh]);
useVisiblePolling(refresh, 5000);
const submit = async (
items: ImportJobItem[],
options: {
+20 -5
View File
@@ -1,7 +1,8 @@
"use client";
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 type { ActionResult } from "@/lib/safe-action-shared";
@@ -35,13 +36,19 @@ interface RunOptions {
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/
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 router = useRouter();
function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
if (locked.current) return;
locked.current = true;
setFieldErrors({});
const {
successMessage = "Saved successfully.",
errorMessage = "An error occurred.",
successMessage = t("saved"),
errorMessage = t("error"),
redirectTo,
onSuccess,
onError,
@@ -61,16 +68,24 @@ export function useServerAction(options: UseServerActionOptions = {}) {
else router.refresh();
}
} else {
toast.error(result.error || errorMessage);
setFieldErrors(result.fieldErrors ?? {});
toast.error(result.error || errorMessage, {
description:
Object.values(result.fieldErrors ?? {})
.flat()
.join(" · ") || undefined,
});
onError?.();
}
} catch (err) {
console.error("[useServerAction] Unexpected error:", err);
toast.error(errorMessage);
onError?.();
} finally {
locked.current = false;
}
});
}
return { run, isPending };
return { run, isPending, fieldErrors };
}
+50
View File
@@ -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]);
}
+38
View File
@@ -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]);
}
+39
View File
@@ -0,0 +1,39 @@
import { readdir, readFile } from "node:fs/promises";
import { parse } from "@formatjs/icu-messageformat-parser";
import { describe, expect, it } from "vitest";
import { flattenObject } from "@/lib/translations-utils";
describe("CMS message catalogs", () => {
it("contains valid ICU messages in every shipped locale", async () => {
const errors: string[] = [];
for (const file of await readdir("src/messages")) {
if (!file.endsWith(".json")) continue;
const messages = flattenObject(
JSON.parse(await readFile(`src/messages/${file}`, "utf8")),
);
for (const [key, value] of Object.entries(messages)) {
try {
parse(value);
} catch {
errors.push(`${file}: ${key}`);
}
}
}
expect(errors).toEqual([]);
});
it("provides actual text for the search result headings and dashboard counts", async () => {
const { createTranslator } = await import("next-intl");
const messages = JSON.parse(await readFile("src/messages/en.json", "utf8"));
const errors: unknown[] = [];
const t = createTranslator({
locale: "en",
messages,
onError: (error) => errors.push(error),
});
expect(t("pages.admin.dashboard.totalAccounts", { count: 12 })).toContain(
"12",
);
expect(t("pages.admin.search.noResults")).not.toContain("pages.");
expect(errors).toEqual([]);
});
});
+49
View File
@@ -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([]);
},
);
+32
View File
@@ -0,0 +1,32 @@
export const SUPPORTED_LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
export function isSupportedLocale(
value: string | undefined,
): value is AppLocale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
+12 -39
View File
@@ -1,41 +1,17 @@
import { cookies, headers } from "next/headers";
import { getRequestConfig } from "next-intl/server";
import { loadCmsMessages } from "@/lib/cms-translations";
import { mergeMessages } from "./merge-messages";
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
// the Accept-Language header, and finally English as fallback.
export const SUPPORTED_LOCALES = [
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
export {
DEFAULT_LOCALE,
isSupportedLocale,
SUPPORTED_LOCALES,
} from "./locales";
export function isSupportedLocale(
value: string | undefined,
): value is AppLocale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
import { type AppLocale, DEFAULT_LOCALE, isSupportedLocale } from "./locales";
function detectLocaleFromAcceptLanguage(
acceptLanguage: string | null,
@@ -73,14 +49,10 @@ export default getRequestConfig(async () => {
locale = detected ?? DEFAULT_LOCALE;
}
// Safe: `locale` is validated against SUPPORTED_LOCALES above (only en/it/nl/de/fr/es).
const messages = (await import(`../messages/${locale}.json`)).default;
// English is the source of truth; fall back to it for any key missing from a
// translation so the UI never shows a raw key path.
const fallback =
locale === DEFAULT_LOCALE
? messages
: (await import(`../messages/${DEFAULT_LOCALE}.json`)).default;
const [messages, fallback] = await Promise.all([
loadCmsMessages(locale),
loadCmsMessages(DEFAULT_LOCALE),
]);
return {
locale,
@@ -108,6 +80,7 @@ export default getRequestConfig(async () => {
if (typeof fromEnglish === "string") return fromEnglish;
const seg = key.split(".").pop() ?? key;
return seg
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[._-]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
+13 -1
View File
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
icon: Server,
prefixes: ["/admin/devops"],
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/cms-errors", labelKey: "cmsErrors" },
],
},
];
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: Server,
permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"],
matchExcludePrefixes: ["/admin/devops/cms-errors"],
},
{
href: "/admin/devops/cms-errors",
labelKey: "cmsErrors",
icon: AlertTriangle,
permission: PERMS.DEVOPS_VIEW,
},
{
href: "/admin/alerts",
+38
View File
@@ -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);
});
+161
View File
@@ -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;
}
+89
View File
@@ -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);
});
+17
View File
@@ -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);
});
+6
View File
@@ -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);
}
+32
View File
@@ -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),
};
});
+27
View File
@@ -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);
});
+47
View File
@@ -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;
}
+36
View File
@@ -0,0 +1,36 @@
import {
type MessageFormatElement,
parse,
TYPE,
} from "@formatjs/icu-messageformat-parser";
/** Argument names and rich-text tags are a contract with the calling component. */
export function messageVariables(value: string): string[] {
const names = new Set<string>();
function visit(nodes: MessageFormatElement[]) {
for (const node of nodes) {
if (node.type !== TYPE.literal && node.type !== TYPE.pound)
names.add(node.value);
if (node.type === TYPE.select || node.type === TYPE.plural) {
for (const option of Object.values(node.options)) visit(option.value);
}
if (node.type === TYPE.tag) visit(node.children);
}
}
visit(parse(value));
return [...names].sort();
}
export function validateCmsMessage(
value: string,
source: string,
): "empty" | "invalid" | "variables" | null {
if (!value.trim()) return "empty";
try {
return messageVariables(value).join("\0") ===
messageVariables(source).join("\0")
? null
: "variables";
} catch {
return "invalid";
}
}
+108
View File
@@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readCmsTranslation, saveCmsTranslation } from "./cms-translations";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories
.splice(0)
.map((dir) => rm(dir, { recursive: true, force: true })),
);
});
async function root() {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-translations-test-"));
directories.push(dir);
return dir;
}
describe("CMS translation persistence", () => {
it("reads bundled messages without source files and persists only overrides", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
expect(before.messages["pages.admin.search.users"]).toBe("Utenti");
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
expect(
(await readCmsTranslation("it", dir)).messages[
"pages.admin.search.users"
],
).toBe("Persone");
expect(
JSON.parse(await readFile(path.join(dir, "it.json"), "utf8")),
).toEqual({ "pages.admin.search.users": "Persone" });
});
it("rejects stale saves and invalid ICU without overwriting saved text", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Stale" },
dir,
),
).rejects.toThrow("conflict");
const current = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "{broken" },
dir,
),
).rejects.toThrow("invalid");
await expect(
saveCmsTranslation(
"it",
current.revision,
{ "pages.admin.dashboard.totalAccounts": "No variable" },
dir,
),
).rejects.toThrow("variables");
expect((await readCmsTranslation("it", dir)).revision).toBe(
current.revision,
);
});
it("restores the bundled value when an override is reset", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
const saved = await saveCmsTranslation(
"it",
before.revision,
{ "pages.admin.search.users": "Persone" },
dir,
);
const reset = await saveCmsTranslation(
"it",
saved.revision,
{ "pages.admin.search.users": null },
dir,
);
expect(reset.messages["pages.admin.search.users"]).toBe("Utenti");
});
it("rejects unknown message keys and does not mutate prototypes", async () => {
const dir = await root();
const before = await readCmsTranslation("it", dir);
await expect(
saveCmsTranslation(
"it",
before.revision,
{ "__proto__.polluted": "yes" },
dir,
),
).rejects.toThrow("unknownKey");
expect(Object.prototype).not.toHaveProperty("polluted");
});
});
+133
View File
@@ -0,0 +1,133 @@
import "server-only";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { cache } from "react";
import { type AppLocale, isSupportedLocale } from "@/i18n/locales";
import type { MessageTree } from "@/i18n/merge-messages";
import { validateCmsMessage } from "./cms-message-validation";
import { logger } from "./logger";
import {
withGamedataLock,
writeGamedataJsonAtomic,
} from "./services/import/core/gamedata-json";
import { flattenObject, unflattenObject } from "./translations-utils";
const defaultRoot = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"storage",
"cms-translations",
);
export interface CmsTranslationSnapshot {
messages: Record<string, string>;
revision: string;
}
export class CmsTranslationError extends Error {
constructor(
public readonly code:
| "conflict"
| "unknownKey"
| "empty"
| "invalid"
| "variables",
public readonly key = "",
) {
super(`${code}: ${key}`);
}
}
async function bundled(locale: AppLocale): Promise<Record<string, string>> {
return flattenObject((await import(`../messages/${locale}.json`)).default);
}
async function readOverrides(file: string): Promise<Record<string, string>> {
try {
const parsed: unknown = JSON.parse(
await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"),
);
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
Object.values(parsed).some((v) => typeof v !== "string")
)
throw new Error("Invalid CMS translation override file");
return parsed as Record<string, string>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
}
function localeFile(locale: AppLocale, root: string) {
if (!isSupportedLocale(locale)) throw new Error("Unsupported locale");
return path.join(root, `${locale}.json`);
}
export async function readCmsTranslation(
locale: AppLocale,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(localeFile(locale, root)),
]);
const messages = { ...base };
for (const [key, value] of Object.entries(overrides)) {
// Obsolete or incompatible overrides must not break new releases.
if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key]))
continue;
messages[key] = value;
}
return {
messages,
revision: createHash("sha256")
.update(JSON.stringify([base, overrides]))
.digest("hex"),
};
}
export async function saveCmsTranslation(
locale: AppLocale,
revision: string,
changes: Record<string, string | null>,
root = defaultRoot,
): Promise<CmsTranslationSnapshot> {
const file = localeFile(locale, root);
await fs.mkdir(root, { recursive: true });
return withGamedataLock(file, async () => {
const current = await readCmsTranslation(locale, root);
if (current.revision !== revision)
throw new CmsTranslationError("conflict");
const [base, source, overrides] = await Promise.all([
bundled(locale),
bundled("en"),
readOverrides(file),
]);
for (const [key, value] of Object.entries(changes)) {
if (!Object.hasOwn(source, key))
throw new CmsTranslationError("unknownKey", key);
if (value === null || value === base[key]) {
delete overrides[key];
continue;
}
const issue = validateCmsMessage(value, source[key]);
if (issue) throw new CmsTranslationError(issue, key);
overrides[key] = value;
}
await writeGamedataJsonAtomic(file, overrides);
return readCmsTranslation(locale, root);
});
}
/** Per-request deduplication; runtime edits are visible on the next request. */
export const loadCmsMessages = cache(
async (locale: AppLocale): Promise<MessageTree> => {
try {
return unflattenObject(
(await readCmsTranslation(locale)).messages,
) as MessageTree;
} catch (error) {
logger.error("CMS translation overrides could not be loaded", {
locale,
error,
});
return (await import(`../messages/${locale}.json`)).default;
}
},
);
+25
View File
@@ -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([]),
);
+10
View File
@@ -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"),
);
}
+67
View File
@@ -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",
]);
});
+32
View File
@@ -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,
});
}
}
+5 -3
View File
@@ -1,7 +1,8 @@
import { logger } from "@/lib/logger";
import { cacheNews } from "@/lib/services/news-cache";
import "server-only";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, WebsiteArticles } from "@/lib/db";
export interface NewsListItem {
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
*/
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
try {
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
db
.select({
slug: WebsiteArticles.slug,
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
createdAt: a.createdAt ? new Date(a.createdAt) : null,
}))
.slice(0, limit);
} catch {
} catch (error) {
logger.error("Public news query failed", { module: "news", error });
return [];
}
}
+78
View File
@@ -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 }]);
});
});
+11 -13
View File
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
recentRooms,
badges,
userSettingsRows,
friendCountRows,
unreadCountRows,
referralsRows,
friends,
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
.where(eq(UsersSettings.userId, userId))
.limit(1)
.catch(() => []),
db
.select({ value: count() })
.from(MessengerFriendships)
.where(
or(
eq(MessengerFriendships.userOneId, userId),
eq(MessengerFriendships.userTwoId, userId),
),
)
.catch(() => [{ value: 0 }]),
db
.select({ value: count() })
.from(MessengerOffline)
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
.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 {
userRows,
hotelName,
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
recentRooms,
badges,
userSettingsRows,
friendCountRows,
friendCountRows: [{ value: uniqueFriends.length }],
unreadCountRows,
referralsRows,
friends,
friends: uniqueFriends,
currencyRows,
};
}
+4 -4
View File
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
"/admin/menu",
);
expect(
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
).toContain("applyAdminNavConfig");
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
"applyAdminNavConfig",
);
expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
"ADMIN_NAV_CONFIG_KEY",
);
});
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "няма зададен API за игра в момента",
"noListenersHint": "няма зададен API за слушатели",
"endpointDiagnostics": "Диагностика на крайна точка",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">ОК</span> достъпно · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> не е конфигуриран · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ГРЕШКА</span> извличането е неуспешно / изтече времето за изчакване.",
"diagnosticsLegend": "OK: достъпно · WARN: не е конфигурирано · ERROR: неуспешна заявка или изтекло време.",
"streamUrl": "URL адрес на потока",
"unreachable": "недостижим",
"refresh": "Опресняване"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Няма зададен radio_stream_url, така че все още няма нищо за вграждане.",
"goToSettings": "Отидете на Радио настройки",
"livePreview": "Преглед на живо",
"livePreviewText": "Това е голият <audio> плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
"livePreviewText": "Това е голият audio плейър, както ще го видят посетителите. Натиснете възпроизвеждане, за да потвърдите, че потокът е достъпен.",
"source": "Източник",
"audioSnippet": "Фрагмент от аудио плейър",
"audioSnippetDesc": "Вмъкващ се HTML5 аудио плейър, насочен право към потока. Работи навсякъде, където е разрешен обикновен HTML.",
@@ -1998,7 +1998,7 @@
"title": "Сигнали",
"subtitle": "Изпращайте сигнали за целия хотел и вижте последните сигнали",
"sendAlert": "Изпратете сигнал за хотел",
"recentAlerts": "Последни сигнали",
"recentAlerts": "Последни сигнали ({count})",
"noRecentAlerts": "Няма скорошни сигнали",
"form": {
"title": "Заглавие на предупреждение",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Импортиране Разрешения",
"importHint": "Paste a JSON array of разрешение objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of разрешение objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Импортиране",
"exportTitle": "Export Разрешения",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Предварително зададени теми",
"presetsSubtitle": "Изберете предварително изградена цветова схема",
"applyPreset": "Кандидатствайте",
"presetApplied": "Предварителната настройка е приложена",
"presetApplied": "Предварителната настройка е приложена: {preset}",
"customiseTitle": "Персонализирани цветове",
"primary": "Първичен",
"secondary": "Вторичен",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "žádná nyní přehrávající sada API",
"noListenersHint": "není nastaveno žádné rozhraní API pro posluchače",
"endpointDiagnostics": "Diagnostika koncových bodů",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> dosažitelné · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-primaryl]-1 bg-8 rounded text-[var(--color-text)]\">WARN</span> není nakonfigurováno · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">CHYBA</span> se nezdařilo / vypršel časový limit.",
"diagnosticsLegend": "OK: dostupné · WARN: nenastaveno · ERROR: požadavek selhal nebo vypršel časový limit.",
"streamUrl": "Adresa URL streamu",
"unreachable": "nedosažitelný",
"refresh": "Obnovit"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Neexistuje žádná sada radio_stream_url, takže zatím není co vkládat.",
"goToSettings": "Přejděte do Nastavení rádia",
"livePreview": "Živý náhled",
"livePreviewText": "Toto je holý <audio> přehrávač, jak jej uvidí návštěvníci. Stisknutím tlačítka Přehrát potvrďte, že je stream dosažitelný.",
"livePreviewText": "Toto je holý audio přehrávač, jak jej uvidí návštěvníci. Stisknutím tlačítka Přehrát potvrďte, že je stream dosažitelný.",
"source": "Zdroj",
"audioSnippet": "Úryvek audio přehrávače",
"audioSnippetDesc": "Zvukový přehrávač HTML5 namířil přímo na stream. Funguje všude, kde je povoleno prosté HTML.",
@@ -1998,7 +1998,7 @@
"title": "Upozornění",
"subtitle": "Odesílejte upozornění pro celý hotel a prohlížejte si nedávná upozornění",
"sendAlert": "Odeslat upozornění na hotel",
"recentAlerts": "Nedávná upozornění",
"recentAlerts": "Nedávná upozornění ({count})",
"noRecentAlerts": "Žádná nedávná upozornění",
"form": {
"title": "Název upozornění",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importovat Oprávnění",
"importHint": "Paste a JSON array of Povolení objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Povolení objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importovat",
"exportTitle": "Export Oprávnění",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Předvolby motivu",
"presetsSubtitle": "Vyberte předem připravené barevné schéma",
"applyPreset": "Použít",
"presetApplied": "Předvolba použita",
"presetApplied": "Předvolba použita: {preset}",
"customiseTitle": "Vlastní barvy",
"primary": "Primární",
"secondary": "Sekundární",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "intet API-sæt, der nu spiller",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Slutpunktsdiagnostik",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> tilgængelig · <span class=\"inline-block text-[0.72rem] font-bold px-2 rounded-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ADVARSEL</span> ikke konfigureret · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text) / 22 text-[var(--farve mislykket] OR.",
"diagnosticsLegend": "OK: tilgængelig · WARN: ikke konfigureret · ERROR: anmodningen mislykkedes eller fik timeout.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Opdater"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Der er ingen radio_stream_url sat, så der er intet at indlejre endnu.",
"goToSettings": "Gå til Radioindstillinger",
"livePreview": "Live preview",
"livePreviewText": "Dette er den blottede <audio>-afspiller, som besøgende vil se den. Tryk på afspil for at bekræfte, at streamen er tilgængelig.",
"livePreviewText": "Dette er den blottede audio-afspiller, som besøgende vil se den. Tryk på afspil for at bekræfte, at streamen er tilgængelig.",
"source": "Kilde",
"audioSnippet": "Uddrag af lydafspiller",
"audioSnippetDesc": "Drop-in HTML5-lydafspiller pegede direkte mod strømmen. Virker overalt, hvor almindelig HTML er tilladt.",
@@ -1998,7 +1998,7 @@
"title": "Advarsler",
"subtitle": "Send underretninger for hele hotellet, og se de seneste underretninger",
"sendAlert": "Send hotelalarm",
"recentAlerts": "Seneste underretninger",
"recentAlerts": "Seneste underretninger ({count})",
"noRecentAlerts": "No recent alerts",
"form": {
"title": "Alert title",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer Tilladelser",
"importHint": "Paste a JSON array of Tilladelse objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tilladelse objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export Tilladelser",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Forudindstillinger af tema",
"presetsSubtitle": "Vælg et forudbygget farveskema",
"applyPreset": "Ansøg",
"presetApplied": "Forudindstilling anvendt",
"presetApplied": "Forudindstilling anvendt: {preset}",
"customiseTitle": "Brugerdefinerede farver",
"primary": "Primær",
"secondary": "Sekundær",
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: erreichbar · WARN: nicht konfiguriert · ERROR: Anfrage fehlgeschlagen oder Zeitüberschreitung.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Aktualisieren"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alarme",
"subtitle": "Hotelweite Alarme senden und aktuelle anzeigen",
"sendAlert": "Hotel-Alarm senden",
"recentAlerts": "Letzte Alarme",
"recentAlerts": "Letzte Alarme ({count})",
"noRecentAlerts": "Keine aktuellen Alarme",
"form": {
"title": "Alarm-Titel",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importieren Berechtigungen",
"importHint": "Paste a JSON array of Berechtigung objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Berechtigung objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importieren",
"exportTitle": "Export Berechtigungen",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Design-Voreinstellungen",
"presetsSubtitle": "Wähle ein vorgefertigtes Farbschema",
"applyPreset": "Anwenden",
"presetApplied": "Voreinstellung angewendet",
"presetApplied": "Voreinstellung angewendet: {preset}",
"customiseTitle": "Benutzerdefinierte Farben",
"primary": "Primär",
"secondary": "Sekundär",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "δεν υπάρχει σύνολο API που παίζει τώρα",
"noListenersHint": "δεν έχει οριστεί API ακροατών",
"endpointDiagnostics": "Διαγνωστικά τελικού σημείου",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">ΟΚ</span> προσβάσιμο · <span class=\"inline-block text-[0.72-rounded-5-0.72remful. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ΠΡΟΕΙΔΟΠΟΙΗΣΗ</span> δεν έχει διαμορφωθεί · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/2 text-[var(--color-text)]\">ΣΦΑΛΜΑ</span> η ανάκτηση απέτυχε / έληξε.",
"diagnosticsLegend": "OK: διαθέσιμο · WARN: δεν έχει ρυθμιστεί · ERROR: αποτυχία αιτήματος ή λήξη χρονικού ορίου.",
"streamUrl": "URL ροής",
"unreachable": "απρόσιτος",
"refresh": "Ανανέωση"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Δεν υπάρχει set radio_stream_url, επομένως δεν υπάρχει τίποτα για ενσωμάτωση ακόμα.",
"goToSettings": "Μεταβείτε στις Ρυθμίσεις ραδιοφώνου",
"livePreview": "Ζωντανή προεπισκόπηση",
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής <audio> όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
"livePreviewText": "Αυτό είναι το γυμνό πρόγραμμα αναπαραγωγής audio όπως θα το δουν οι επισκέπτες. Πατήστε το play για να επιβεβαιώσετε ότι η ροή είναι προσβάσιμη.",
"source": "Πηγή",
"audioSnippet": "Απόσπασμα αναπαραγωγής ήχου",
"audioSnippetDesc": "Το αναπτυσσόμενο πρόγραμμα αναπαραγωγής ήχου HTML5 κατευθείαν στη ροή. Λειτουργεί οπουδήποτε επιτρέπεται απλό HTML.",
@@ -1998,7 +1998,7 @@
"title": "Ειδοποιήσεις",
"subtitle": "Στείλτε ειδοποιήσεις σε όλο το ξενοδοχείο και δείτε πρόσφατες ειδοποιήσεις",
"sendAlert": "Αποστολή ειδοποίησης ξενοδοχείου",
"recentAlerts": "Πρόσφατες ειδοποιήσεις",
"recentAlerts": "Πρόσφατες ειδοποιήσεις ({count})",
"noRecentAlerts": "Δεν υπάρχουν πρόσφατες ειδοποιήσεις",
"form": {
"title": "Τίτλος ειδοποίησης",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Εισαγωγή Άδειες",
"importHint": "Paste a JSON array of Άδεια objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Άδεια objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Εισαγωγή",
"exportTitle": "Export Άδειες",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Προεπιλογές θεμάτων",
"presetsSubtitle": "Επιλέξτε έναν προκατασκευασμένο συνδυασμό χρωμάτων",
"applyPreset": "Εφαρμόστε",
"presetApplied": "Εφαρμόστηκε η προεπιλογή",
"presetApplied": "Εφαρμόστηκε η προεπιλογή: {preset}",
"customiseTitle": "Προσαρμοσμένα χρώματα",
"primary": "Πρωτοβάθμια",
"secondary": "Δευτερεύουσα",
+387 -36
View File
@@ -878,7 +878,8 @@
"codePlaceholder": "6-digit code",
"confirm": "Confirm",
"enableIntro": "Add a second layer of security with an authenticator app.",
"enable": "Enable 2FA"
"enable": "Enable 2FA",
"rateLimit": "Too many attempts. Please wait before trying again."
},
"login": {
"title": "Sign in",
@@ -1048,7 +1049,7 @@
"system": "System",
"hubTools": "Tools",
"hubObservability": "Observability",
"commandocentrum": "Commandocentrum",
"commandocentrum": "Command center",
"emulator": "Emulator",
"alerts": "Alerts",
"logs": "Logs",
@@ -1094,7 +1095,15 @@
"guilds": "Guilds",
"shop": "Shop",
"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": {
"title": "Dashboard",
@@ -1111,7 +1120,8 @@
"rankWithCount": "Rank {rank}: {count} accounts",
"recentStaffActivity": "Recent staff activity",
"viewAll": "View all",
"noStaffActivityLogged": "No staff activity logged yet"
"noStaffActivityLogged": "No staff activity logged yet",
"totalAccounts": "{count} total accounts"
},
"articles": {
"title": "Articles",
@@ -1151,8 +1161,26 @@
"statusScheduled": "Scheduled",
"statusPublished": "Published",
"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": {
"newEvent": "+ New event",
@@ -1252,7 +1280,8 @@
},
"devops": {
"noEmulatorErrors": "No emulator errors found",
"noRecentErrors": "No recent errors"
"noRecentErrors": "No recent errors",
"cmsErrors": "CMS error center"
},
"tags": {
"title": "Tags",
@@ -1272,7 +1301,13 @@
"colColour": "Colour",
"colUsedBy": "Used by",
"colCreated": "Created",
"colActions": "Actions"
"colActions": "Actions",
"databaseError": "Tags could not be loaded. Please try again.",
"totalTags": "Total tags",
"inUse": "In use",
"attachedToContent": "Attached to content",
"unused": "Unused",
"allTags": "All tags"
},
"navigation": {
"title": "Navigator",
@@ -1296,7 +1331,12 @@
"colName": "Name",
"colOrder": "Order",
"colRooms": "Rooms",
"colActions": "Actions"
"colActions": "Actions",
"publicFlatcats": "Public room categories",
"markedPublic": "Categories marked as public",
"visibleRooms": "Visible rooms",
"rows": "rows",
"unnamed": "Unnamed room"
},
"helpCenter": {
"title": "Help Center",
@@ -1323,7 +1363,9 @@
"saving": "Saving…",
"deleteEntry": "Delete entry",
"cancel": "Cancel"
}
},
"withButton": "With a button",
"smallBoxes": "Small boxes"
},
"ads": {
"title": "Advertisements",
@@ -1385,8 +1427,17 @@
"save": "Save box",
"saving": "Saving…",
"deleteBox": "Delete box",
"cancel": "Cancel"
}
"cancel": "Cancel",
"iconEmoji": "Icon or emoji"
},
"totalBoxes": "Total boxes",
"active": "Active",
"shownOnHomePage": "Shown on the home page",
"position": "Position",
"boxes": "Content boxes",
"hidden": "Hidden",
"hide": "Hide",
"activate": "Activate"
},
"photos": {
"title": "Photo Moderation",
@@ -1513,7 +1564,20 @@
"save": "Save ban",
"saving": "Saving…",
"cancel": "Cancel"
}
},
"activeBans": "Active bans",
"permanent": "Permanent bans",
"accountBans": "Account bans",
"userIdPlaceholder": "User ID",
"typeAccount": "Account",
"typeIp": "IP address",
"typeMachine": "Device",
"typeSuper": "Account, IP and device",
"hoursPlaceholder": "Duration in hours",
"reasonPlaceholder": "Reason for the ban",
"ban": "Ban user",
"colUserId": "User ID",
"lift": "Lift ban"
},
"ip": {
"title": "IP Management",
@@ -1533,7 +1597,11 @@
"colIp": "IP",
"colReason": "Reason",
"colAdded": "Added",
"colActions": "Actions"
"colActions": "Actions",
"whitelisted": "Allowed IP addresses",
"blacklisted": "Blocked IP addresses",
"asnPlaceholder": "ASN (optional)",
"colAsn": "ASN"
},
"vpn": {
"title": "VPN / Proxy Detection",
@@ -1546,7 +1614,21 @@
"blockOnDetectHint": "Automatically block users detected behind a VPN or proxy",
"save": "Save settings",
"saving": "Saving…",
"settingsSaved": "VPN settings saved successfully"
"settingsSaved": "VPN settings saved successfully",
"protection": "VPN protection",
"on": "Enabled",
"off": "Disabled",
"providerLabel": "Provider: {provider}",
"notScreened": "Connections are not checked for VPN use.",
"settings": "VPN settings",
"provider": "Provider",
"providerHint": "Choose the service used to check incoming connections.",
"blockMessage": "Blocked connection message",
"blockMessagePlaceholder": "Please disable your VPN and try again.",
"blockMessageHint": "Shown when a connection is blocked by VPN protection.",
"rawIpTitle": "IP address lists",
"rawIpDescription": "Manage individual addresses in <ipLink>IP management</ipLink>.",
"openIpManagement": "Open IP management"
},
"tickets": {
"title": "CMS support desk",
@@ -1676,7 +1758,10 @@
"status": "Status",
"submitted": "Submitted",
"noMessage": "No message provided"
}
},
"applicationsTitle": "Applications ({count})",
"userNumber": "User #{id}",
"rankLabel": "Rank {rank}"
},
"teams": {
"title": "Teams",
@@ -1704,7 +1789,26 @@
"save": "Save team definition",
"saving": "Saving…",
"cancel": "Cancel"
}
},
"teamRanks": "Staff ranks",
"visible": "Visible",
"hidden": "Hidden",
"newTeamRank": "New staff rank",
"rankName": "Rank name",
"rankNamePlaceholder": "For example: Moderator",
"badgeCode": "Badge code",
"badgeCodePlaceholder": "For example: ADM",
"jobDescription": "Job description",
"jobDescriptionPlaceholder": "Describe this staff role",
"staffColor": "Staff colour",
"staffColorPlaceholder": "For example: #3366cc",
"hiddenRank": "Hide from the staff page",
"createTeam": "Create staff rank",
"ranksTitle": "Staff ranks ({count})",
"badge": "Badge",
"color": "Colour",
"visibility": "Visibility",
"delete": "Delete"
},
"permissions": {
"title": "Permissions",
@@ -1879,8 +1983,23 @@
"enabled": "Enabled",
"save": "Save package",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"infoPlaceholder": "Describe the package contents",
"iconPlaceholder": "Icon URL",
"colorPlaceholder": "For example: #3366cc",
"costsPlaceholder": "Price",
"creditsPlaceholder": "Number of credits",
"ducketsPlaceholder": "Number of duckets",
"diamondsPlaceholder": "Number of diamonds",
"giveRankPlaceholder": "Rank ID (0 for none)",
"badgesPlaceholder": "Badge codes separated by semicolons",
"positionPlaceholder": "Display order",
"create": "Create package",
"deletePackage": "Delete package"
},
"newPackageTitle": "New shop package",
"backToShop": "Back to shop",
"editPackageTitle": "Edit shop package"
},
"marketplace": {
"title": "Marketplace",
@@ -2047,7 +2166,15 @@
"colActions": "Actions",
"statusActive": "Active",
"statusCancelled": "Cancelled",
"statusExpired": "Expired"
"statusExpired": "Expired",
"activeSubs": "Active subscriptions",
"types": "Subscription types",
"expiringSoon": "Expiring soon",
"activeSubscriptions": "Active subscriptions",
"colSubId": "Subscription ID",
"colUserId": "User ID",
"colType": "Type",
"colExpires": "Expires"
},
"rareValues": {
"title": "Rare Values",
@@ -2086,8 +2213,24 @@
"pointTypeDiamonds": "Diamonds",
"save": "Save item",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"itemIdPlaceholder": "Item ID",
"iconPlaceholder": "Icon URL",
"creditsPlaceholder": "Credits",
"currencyValuePlaceholder": "Currency value",
"addValue": "Add value"
},
"badgePlaceholder": "Category badge code",
"priorityPlaceholder": "Priority",
"addCategoryButton": "Add category",
"badgeLabel": "Badge: {badge}",
"priorityLabel": "Priority: {priority}",
"itemCount": "{count} items",
"colItemId": "Item ID",
"colCurrencyValue": "Currency value",
"colCurrency": "Currency",
"colIcon": "Icon",
"noValuesInCategory": "No values in this category yet."
},
"badges": {
"title": "Badges",
@@ -2118,7 +2261,10 @@
"colImage": "Image",
"colName": "Name",
"colDescription": "Description",
"colActions": "Actions"
"colActions": "Actions",
"uploadedMessage": "Badge {code} uploaded.",
"errorMessage": "Upload failed: {error}",
"userIdPlaceholder": "User ID"
},
"achievements": {
"title": "Achievements",
@@ -2132,7 +2278,14 @@
"colProgress": "Progress",
"levelLabel": "Level {level}",
"rewardCredits": "{count} credits",
"rewardPoints": "{count} {points}"
"rewardPoints": "{count} {points}",
"achievementRows": "Achievement levels",
"distinctAchievements": "Distinct achievements",
"categories": "Categories",
"colLevel": "Level",
"colRewardType": "Reward type",
"colRewardAmount": "Reward amount",
"colPoints": "Points"
},
"calendar": {
"title": "Calendar campaigns",
@@ -2151,8 +2304,32 @@
"days": "Days",
"dayLabel": "Day {day}",
"reward": "Reward",
"noDays": "No days configured for this campaign"
}
"noDays": "No days configured for this campaign",
"campaignMeta": "#{id} · {days} days · starts {date}",
"disabled": "Disabled",
"enabled": "Enabled",
"locksExpired": "Expired days are locked",
"rewards": "Rewards",
"noRewards": "No rewards configured.",
"colId": "ID",
"colProduct": "Product",
"colItem": "Item",
"colCredits": "Credits",
"colPixels": "Duckets",
"colPoints": "Points",
"colBadge": "Badge",
"colSubscription": "Subscription",
"typeLabel": "Type {type}",
"rewardCount": "{count} rewards"
},
"campaigns": "Campaigns",
"enabled": "Enabled campaigns",
"totalRewards": "Total rewards",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"campaignMeta": "#{id} · {days} days · {rewards} rewards",
"startsAt": "Starts: {date}",
"locksExpired": "Expired days are locked"
},
"radio": {
"title": "Radio Moderation",
@@ -2222,7 +2399,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2346,7 +2523,8 @@
"pointsPerMinuteHint": "Amount awarded for each full minute of listening.",
"maxPerDayHint": "Daily cap per user. Use 0 for unlimited.",
"minListenersHint": "Points are only awarded when at least this many people are tuned in.",
"save": "Save points settings"
"save": "Save points settings",
"saved": "Settings saved."
},
"autoDjForm": {
"title": "Radio AutoDJ",
@@ -2399,7 +2577,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: reachable · WARN: not configured · ERROR: request failed or timed out.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Refresh"
@@ -2580,7 +2758,16 @@
"valuePlaceholder": "Enter value…",
"save": "Save changes",
"cancel": "Cancel"
}
},
"settingsKeys": "Settings",
"textKeys": "Text entries",
"keysCount": "{count} keys",
"addOverwriteSetting": "Add or update a setting",
"key": "Key",
"value": "Value",
"showingFirst": "Showing the first {count}",
"addOverwriteText": "Add or update a text",
"truncatedHint": "Showing {shown} of {total} entries."
},
"alerts": {
"title": "Alerts",
@@ -2790,7 +2977,8 @@
"devops": "DevOps",
"errors": "Errors",
"alerts": "Alerts",
"toolsGroup": "Tools"
"toolsGroup": "Tools",
"cmsErrors": "CMS errors"
},
"events": "Events",
"eventsSubtitle": "Hotel events and event types",
@@ -2847,7 +3035,25 @@
"save": "Save settings",
"saving": "Saving…",
"confirmEnable": "Enabling maintenance will prevent all non-staff users from accessing the hotel. Continue?",
"confirmDisable": "Disable maintenance mode and restore access for all users?"
"confirmDisable": "Disable maintenance mode and restore access for all users?",
"maintenance": "Maintenance",
"hotelLockedDown": "Access to the hotel is restricted.",
"hotelOpen": "The hotel is open.",
"minLoginRank": "Minimum login rank",
"duringMaintenance": "During maintenance",
"splashMessage": "Maintenance message",
"set": "Configured",
"empty": "Empty",
"currentState": "Current status",
"maintenanceCurrently": "Maintenance: {status}",
"onlyRankCanLogin": "Only users with rank {rank} or higher can log in.",
"hotelOpenToEveryone": "All eligible users can log in.",
"settings": "Settings",
"enableMaintenance": "Enable maintenance mode",
"minLoginRankLabel": "Minimum rank allowed during maintenance",
"maintenanceMessageLabel": "Message shown to visitors",
"maintenanceMessagePlaceholder": "Explain why the hotel is unavailable.",
"saveChanges": "Save changes"
},
"housekeeping": {
"title": "Permission archive",
@@ -2931,7 +3137,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Import permissions",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects with the fields permission, minRank, description, groupName and dependsOn.",
"importPlaceholder": "Paste JSON here…",
"import": "Import",
"exportTitle": "Export permissions",
@@ -3048,8 +3254,13 @@
"variablesHint": "{username}, {email}, {hotel_name}",
"save": "Save template",
"saving": "Saving…",
"cancel": "Cancel"
}
"cancel": "Cancel",
"variablesPlaceholder": "Variables, separated by commas",
"active": "Active"
},
"create": "Create template",
"save": "Save template",
"updated": "Updated: {date}"
},
"theme": {
"title": "Theme",
@@ -3075,7 +3286,12 @@
"saveCss": "Save CSS",
"savingCss": "Saving…",
"reset": "Reset to defaults",
"confirmReset": "Reset theme to default values?"
"confirmReset": "Reset theme to default values?",
"saved": "Theme settings saved.",
"presetApplied": "Preset applied: {preset}",
"typographyLayout": "Typography and layout",
"font": "Font",
"borderRadius": "Corner radius"
},
"media": {
"title": "Media Library",
@@ -3160,6 +3376,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "CMS translations",
"description": "Edit the text shown on the website and in housekeeping. Saved changes are applied on the next page load and survive deployments.",
"export": "Export JSON",
"saving": "Saving…",
"saveLocale": "Save {locale}",
"saved": "Translations saved and applied.",
"readOnly": "You can review and export translations. Editing requires permission to change settings.",
"language": "Language",
"coverage": "{total} keys · {missing} missing · {identical} identical to English",
"search": "Search",
"searchPlaceholder": "Key, translation or English source…",
"group": "Group",
"allGroups": "All groups",
"status": "Show",
"all": "All texts",
"missing": "Missing translations",
"identical": "Identical to English",
"modified": "Unsaved changes",
"fallbackHint": "Missing translations use English. Identical text may be intentional. Keep variable names inside braces unchanged.",
"unsaved": "{count} unsaved changes in this language",
"discard": "Discard changes",
"discardConfirm": "Discard unsaved changes in this language?",
"fixErrors": "Fix {count} invalid messages before saving.",
"noResults": "No texts match these filters.",
"source": "English reference",
"missingPlaceholder": "Translation missing; English is used.",
"page": "Page {page} of {total} · {count} keys",
"previous": "Previous",
"next": "Next",
"leaveWarning": "You have unsaved translations. Leave this page?",
"conflict": "These translations changed since you opened the editor. Export your changes, then reload before saving.",
"unknownKey": "Unknown translation key: {key}",
"empty": "Enter a translation for {key}, or discard this change.",
"invalid": "Invalid message syntax in {key}. Check braces and rich-text tags.",
"variables": "Keep the same variable names and rich-text tags as the English source in {key}."
}
},
"settings": {
@@ -3284,6 +3537,104 @@
"less": "Less",
"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": {
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: accesible · WARN: sin configurar · ERROR: solicitud fallida o tiempo agotado.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Actualizar"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alertas",
"subtitle": "Enviar alertas a todo el hotel y ver alertas recientes",
"sendAlert": "Enviar alerta al hotel",
"recentAlerts": "Alertas recientes",
"recentAlerts": "Alertas recientes ({count})",
"noRecentAlerts": "No hay alertas recientes",
"form": {
"title": "Título de la alerta",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importar Permisos",
"importHint": "Paste a JSON array of Permiso objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permiso objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importar",
"exportTitle": "Export Permisos",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Preajustes de tema",
"presetsSubtitle": "Elige un esquema de color predefinido",
"applyPreset": "Aplicar",
"presetApplied": "Preajuste aplicado",
"presetApplied": "Preajuste aplicado: {preset}",
"customiseTitle": "Colores personalizados",
"primary": "Primario",
"secondary": "Secundario",
+5 -5
View File
@@ -1679,7 +1679,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK : accessible · WARN : non configuré · ERROR : échec de la requête ou délai dépassé.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Actualiser"
@@ -1738,7 +1738,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1969,7 +1969,7 @@
"title": "Alertes",
"subtitle": "Envoyer des alertes à tout l'hôtel et voir les alertes récentes",
"sendAlert": "Envoyer une alerte à l'hôtel",
"recentAlerts": "Alertes récentes",
"recentAlerts": "Alertes récentes ({count})",
"noRecentAlerts": "Aucune alerte récente",
"form": {
"title": "Titre de l'alerte",
@@ -2180,7 +2180,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer permissions",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export permissions",
@@ -2317,7 +2317,7 @@
"presetsTitle": "Préréglages de thème",
"presetsSubtitle": "Choisir un jeu de couleurs prédéfini",
"applyPreset": "Appliquer",
"presetApplied": "Préréglage appliqué",
"presetApplied": "Préréglage appliqué: {preset}",
"customiseTitle": "Couleurs personnalisées",
"primary": "Primaire",
"secondary": "Secondaire",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nema postavljenog API-ja koji se sada igra",
"noListenersHint": "nije postavljen API slušatelja",
"endpointDiagnostics": "Dijagnostika krajnje točke",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> dostupno · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> nije konfigurirano · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">GREŠKA</span> dohvaćanje nije uspjelo / isteklo je vrijeme.",
"diagnosticsLegend": "OK: dostupno · WARN: nije konfigurirano · ERROR: zahtjev nije uspio ili je istekao.",
"streamUrl": "URL streama",
"unreachable": "nedostižan",
"refresh": "Osvježi"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nije postavljen radio_stream_url, tako da još nema ništa za ugraditi.",
"goToSettings": "Idite na Radio Settings",
"livePreview": "Pregled uživo",
"livePreviewText": "Ovo je goli <audio> player kako će ga posjetitelji vidjeti. Pritisnite play kako biste potvrdili da je stream dostupan.",
"livePreviewText": "Ovo je goli audio player kako će ga posjetitelji vidjeti. Pritisnite play kako biste potvrdili da je stream dostupan.",
"source": "Izvor",
"audioSnippet": "Isječak audio playera",
"audioSnippetDesc": "Umetnuti HTML5 audio player usmjeren ravno prema streamu. Radi svugdje gdje je dopušten obični HTML.",
@@ -1998,7 +1998,7 @@
"title": "upozorenja",
"subtitle": "Šaljite upozorenja cijelom hotelu i pogledajte nedavna upozorenja",
"sendAlert": "Pošalji hotelsko upozorenje",
"recentAlerts": "Nedavna upozorenja",
"recentAlerts": "Nedavna upozorenja ({count})",
"noRecentAlerts": "Nema nedavnih upozorenja",
"form": {
"title": "Naslov upozorenja",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Uvoz Dozvole",
"importHint": "Paste a JSON array of Dozvola objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Dozvola objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "uvoz",
"exportTitle": "Export Dozvole",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Unaprijed postavljene teme",
"presetsSubtitle": "Odaberite unaprijed pripremljenu shemu boja",
"applyPreset": "primijeniti",
"presetApplied": "Unaprijed postavljeno",
"presetApplied": "Unaprijed postavljeno: {preset}",
"customiseTitle": "Prilagođene boje",
"primary": "Primarni",
"secondary": "Sekundarna",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nincs jelenleg lejátszott API-készlet",
"noListenersHint": "nincs hallgató API beállítva",
"endpointDiagnostics": "Végpont diagnosztika",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> elérhető · <span class=\"inline-block text-[0.72rem] font-bold.5 round-0 px-2 edpy-0 px-2 ed bg-[var(--color-primary)]/18 text-[var(--color-text)]\">FIGYELMEZTETÉS</span> nincs konfigurálva · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(]--ERtch failed-R)\">fe.",
"diagnosticsLegend": "OK: elérhető · WARN: nincs beállítva · ERROR: sikertelen kérés vagy időtúllépés.",
"streamUrl": "Stream URL",
"unreachable": "elérhetetlen",
"refresh": "Frissítés"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nincs beállítva radio_stream_url, így még nincs mit beágyazni.",
"goToSettings": "Menjen a Rádióbeállításokhoz",
"livePreview": "Élő előnézet",
"livePreviewText": "Ez a csupasz <audio> lejátszó, ahogy a látogatók látni fogják. Nyomja meg a lejátszás gombot, hogy megerősítse, hogy az adatfolyam elérhető.",
"livePreviewText": "Ez a csupasz audio lejátszó, ahogy a látogatók látni fogják. Nyomja meg a lejátszás gombot, hogy megerősítse, hogy az adatfolyam elérhető.",
"source": "Forrás",
"audioSnippet": "Hanglejátszó részlet",
"audioSnippetDesc": "A beugró HTML5 audiolejátszó egyenesen a streamre mutatott. Mindenhol működik, ahol a sima HTML megengedett.",
@@ -1998,7 +1998,7 @@
"title": "Figyelmeztetések",
"subtitle": "A szálloda egészére kiterjedő figyelmeztetések küldése és a legutóbbi figyelmeztetések megtekintése",
"sendAlert": "Szállodai figyelmeztetés küldése",
"recentAlerts": "Legutóbbi figyelmeztetések",
"recentAlerts": "Legutóbbi figyelmeztetések ({count})",
"noRecentAlerts": "Nincsenek friss figyelmeztetések",
"form": {
"title": "Figyelmeztetés címe",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importálás Engedélyek",
"importHint": "Paste a JSON array of Engedély objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Engedély objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importálás",
"exportTitle": "Export Engedélyek",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Előre beállított téma",
"presetsSubtitle": "Válasszon egy előre elkészített színsémát",
"applyPreset": "Alkalmazni",
"presetApplied": "Előbeállítás alkalmazva",
"presetApplied": "Előbeállítás alkalmazva: {preset}",
"customiseTitle": "Egyedi színek",
"primary": "Elsődleges",
"secondary": "Másodlagos",
+429 -40
View File
@@ -848,7 +848,8 @@
"codePlaceholder": "Codice a 6 cifre",
"confirm": "Conferma",
"enableIntro": "Aggiungi un livello di sicurezza in più con un'app di autenticazione.",
"enable": "Attiva 2FA"
"enable": "Attiva 2FA",
"rateLimit": "Troppi tentativi. Attendi prima di riprovare."
},
"title": "Account collegati",
"subtitle": "Collega un account social per poter accedere con un clic",
@@ -906,7 +907,12 @@
"alreadyHaveAccountPre": "Hai già un account?",
"alreadyHaveAccountLink": "Accedi",
"whoIsOnline": "Chi è online",
"usersOnline": "{count} online"
"usersOnline": "{count} online",
"accepted": "Accettato e confermato",
"showPassword": "Mostra",
"hidePassword": "Nascondi",
"accountDetails": "Dettagli dell’account",
"credentials": "Credenziali e sicurezza"
},
"forgot": {
"title": "Reimposta password",
@@ -1052,7 +1058,26 @@
"tools": "Strumenti",
"guilds": "Gruppi",
"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": {
"title": "Dashboard",
@@ -1069,7 +1094,8 @@
"rankWithCount": "Grado {rank}: {count} account",
"recentStaffActivity": "Attività recente dello staff",
"viewAll": "Vedi tutto",
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata"
"noStaffActivityLogged": "Nessuna attività dello staff ancora registrata",
"totalAccounts": "{count} account totali"
},
"articles": {
"title": "Articoli",
@@ -1103,8 +1129,32 @@
"saving": "Salvataggio…",
"deleteArticle": "Elimina articolo",
"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": {
"newEvent": "+ Nuovo evento",
@@ -1204,7 +1254,8 @@
},
"devops": {
"noEmulatorErrors": "Nessun errore emulator trovato",
"noRecentErrors": "Nessun errore recente"
"noRecentErrors": "Nessun errore recente",
"cmsErrors": "Centro errori CMS"
},
"tags": {
"title": "Tag",
@@ -1224,7 +1275,13 @@
"colColour": "Colore",
"colUsedBy": "Usato da",
"colCreated": "Creato",
"colActions": "Azioni"
"colActions": "Azioni",
"databaseError": "Impossibile caricare i tag. Riprova.",
"totalTags": "Tag totali",
"inUse": "In uso",
"attachedToContent": "Associati a contenuti",
"unused": "Non utilizzati",
"allTags": "Tutti i tag"
},
"navigation": {
"title": "Navigatore",
@@ -1248,7 +1305,12 @@
"colName": "Nome",
"colOrder": "Ordine",
"colRooms": "Stanze",
"colActions": "Azioni"
"colActions": "Azioni",
"publicFlatcats": "Categorie delle stanze pubbliche",
"markedPublic": "Categorie contrassegnate come pubbliche",
"visibleRooms": "Stanze visibili",
"rows": "righe",
"unnamed": "Stanza senza nome"
},
"helpCenter": {
"title": "Centro assistenza",
@@ -1275,7 +1337,9 @@
"saving": "Salvataggio…",
"deleteEntry": "Elimina voce",
"cancel": "Annulla"
}
},
"withButton": "Con pulsante",
"smallBoxes": "Riquadri piccoli"
},
"ads": {
"title": "Pubblicità",
@@ -1338,8 +1402,17 @@
"save": "Salva box",
"saving": "Salvataggio…",
"deleteBox": "Elimina box",
"cancel": "Annulla"
}
"cancel": "Annulla",
"iconEmoji": "Icona o emoji"
},
"totalBoxes": "Riquadri totali",
"active": "Attivo",
"shownOnHomePage": "Mostrato nella pagina iniziale",
"position": "Posizione",
"boxes": "Riquadri di contenuto",
"hidden": "Nascosto",
"hide": "Nascondi",
"activate": "Attiva"
},
"photos": {
"title": "Moderazione foto",
@@ -1466,7 +1539,20 @@
"save": "Salva ban",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"activeBans": "Ban attivi",
"permanent": "Ban permanenti",
"accountBans": "Ban degli account",
"userIdPlaceholder": "ID utente",
"typeAccount": "Account",
"typeIp": "Indirizzo IP",
"typeMachine": "Dispositivo",
"typeSuper": "Account, IP e dispositivo",
"hoursPlaceholder": "Durata in ore",
"reasonPlaceholder": "Motivo del ban",
"ban": "Banna utente",
"colUserId": "ID utente",
"lift": "Revoca ban"
},
"ip": {
"title": "Gestione IP",
@@ -1486,7 +1572,11 @@
"colIp": "IP",
"colReason": "Motivo",
"colAdded": "Aggiunto",
"colActions": "Azioni"
"colActions": "Azioni",
"whitelisted": "Indirizzi IP consentiti",
"blacklisted": "Indirizzi IP bloccati",
"asnPlaceholder": "ASN (facoltativo)",
"colAsn": "ASN"
},
"vpn": {
"title": "Rilevamento VPN / Proxy",
@@ -1499,7 +1589,21 @@
"blockOnDetectHint": "Blocca automaticamente gli utenti rilevati dietro VPN o proxy",
"save": "Salva impostazioni",
"saving": "Salvataggio…",
"settingsSaved": "Impostazioni VPN salvate con successo"
"settingsSaved": "Impostazioni VPN salvate con successo",
"protection": "Protezione VPN",
"on": "Attiva",
"off": "Disattivata",
"providerLabel": "Provider: {provider}",
"notScreened": "Le connessioni non vengono controllate per l’uso di VPN.",
"settings": "Impostazioni VPN",
"provider": "Provider",
"providerHint": "Scegli il servizio per controllare le connessioni in entrata.",
"blockMessage": "Messaggio per le connessioni bloccate",
"blockMessagePlaceholder": "Disattiva la VPN e riprova.",
"blockMessageHint": "Mostrato quando una connessione viene bloccata dalla protezione VPN.",
"rawIpTitle": "Elenchi degli indirizzi IP",
"rawIpDescription": "Gestisci i singoli indirizzi in <ipLink>Gestione IP</ipLink>.",
"openIpManagement": "Apri gestione IP"
},
"tickets": {
"title": "Supporto CMS",
@@ -1605,7 +1709,10 @@
"status": "Stato",
"submitted": "Inviata",
"noMessage": "Nessun messaggio fornito"
}
},
"applicationsTitle": "Candidature ({count})",
"userNumber": "Utente #{id}",
"rankLabel": "Grado {rank}"
},
"teams": {
"title": "Team",
@@ -1633,7 +1740,26 @@
"save": "Salva definizione team",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"teamRanks": "Gradi dello staff",
"visible": "Visibili",
"hidden": "Nascosti",
"newTeamRank": "Nuovo grado dello staff",
"rankName": "Nome del grado",
"rankNamePlaceholder": "Ad esempio: Moderatore",
"badgeCode": "Codice del distintivo",
"badgeCodePlaceholder": "Ad esempio: ADM",
"jobDescription": "Descrizione dell’incarico",
"jobDescriptionPlaceholder": "Descrivi questo incarico dello staff",
"staffColor": "Colore dello staff",
"staffColorPlaceholder": "Ad esempio: #3366cc",
"hiddenRank": "Nascondi dalla pagina staff",
"createTeam": "Crea grado dello staff",
"ranksTitle": "Gradi dello staff ({count})",
"badge": "Distintivo",
"color": "Colore",
"visibility": "Visibilità",
"delete": "Elimina"
},
"permissions": {
"title": "Permessi",
@@ -1776,8 +1902,23 @@
"enabled": "Attivo",
"save": "Salva pacchetto",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"infoPlaceholder": "Descrivi il contenuto del pacchetto",
"iconPlaceholder": "URL dell’icona",
"colorPlaceholder": "Ad esempio: #3366cc",
"costsPlaceholder": "Prezzo",
"creditsPlaceholder": "Numero di crediti",
"ducketsPlaceholder": "Numero di duckets",
"diamondsPlaceholder": "Numero di diamanti",
"giveRankPlaceholder": "ID grado (0 per nessuno)",
"badgesPlaceholder": "Codici dei distintivi separati da punto e virgola",
"positionPlaceholder": "Ordine di visualizzazione",
"create": "Crea pacchetto",
"deletePackage": "Elimina pacchetto"
},
"newPackageTitle": "Nuovo pacchetto del negozio",
"backToShop": "Torna al negozio",
"editPackageTitle": "Modifica pacchetto del negozio"
},
"marketplace": {
"title": "Marketplace",
@@ -1821,7 +1962,20 @@
"back": "Torna ai gruppi",
"viewPublic": "Apri pagina pubblica",
"disband": "Sciogli gruppo",
"listHeading": "Guilds ({count})"
"listHeading": "Guilds ({count})",
"edit": "Modifica gruppo",
"save": "Salva",
"saving": "Salvataggio…",
"updated": "Gruppo aggiornato",
"updateError": "Impossibile aggiornare il gruppo",
"formName": "Nome",
"formDescription": "Descrizione",
"formState": "Stato",
"formForum": "Forum",
"formReadForum": "Leggere il forum",
"formPostMessages": "Pubblicare messaggi",
"formPostThreads": "Creare discussioni",
"formModForum": "Moderare il forum"
},
"transactions": {
"title": "Transazioni",
@@ -1910,7 +2064,9 @@
"save": "Salva buono",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
},
"updated": "Voucher aggiornato",
"updateError": "Impossibile aggiornare il voucher"
},
"subscriptions": {
"title": "Abbonamenti",
@@ -1929,7 +2085,15 @@
"colActions": "Azioni",
"statusActive": "Attivo",
"statusCancelled": "Annullato",
"statusExpired": "Scaduto"
"statusExpired": "Scaduto",
"activeSubs": "Abbonamenti attivi",
"types": "Tipi di abbonamento",
"expiringSoon": "In scadenza",
"activeSubscriptions": "Abbonamenti attivi",
"colSubId": "ID abbonamento",
"colUserId": "ID utente",
"colType": "Tipo",
"colExpires": "Scadenza"
},
"rareValues": {
"title": "Valori rari",
@@ -1967,8 +2131,25 @@
"pointTypeDiamonds": "Diamanti",
"save": "Salva articolo",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"itemIdPlaceholder": "ID furni",
"iconPlaceholder": "URL dell’icona",
"creditsPlaceholder": "Crediti",
"currencyValuePlaceholder": "Valore in valuta",
"addValue": "Aggiungi valore"
},
"badgePlaceholder": "Codice del distintivo della categoria",
"priorityPlaceholder": "Priorità",
"addCategoryButton": "Aggiungi categoria",
"badgeLabel": "Distintivo: {badge}",
"priorityLabel": "Priorità: {priority}",
"itemCount": "{count} furni",
"colItemId": "ID furni",
"colCurrencyValue": "Valore in valuta",
"colCurrency": "Valuta",
"colIcon": "Icona",
"noValuesInCategory": "Nessun valore ancora presente in questa categoria.",
"editValue": "Modifica valore"
},
"badges": {
"title": "Distintivi",
@@ -1999,7 +2180,10 @@
"colImage": "Immagine",
"colName": "Nome",
"colDescription": "Descrizione",
"colActions": "Azioni"
"colActions": "Azioni",
"uploadedMessage": "Distintivo {code} caricato.",
"errorMessage": "Caricamento non riuscito: {error}",
"userIdPlaceholder": "ID utente"
},
"achievements": {
"title": "Trofei",
@@ -2013,7 +2197,14 @@
"colProgress": "Progresso",
"levelLabel": "Livello {level}",
"rewardCredits": "{count} crediti",
"rewardPoints": "{count} {points}"
"rewardPoints": "{count} {points}",
"achievementRows": "Livelli dei traguardi",
"distinctAchievements": "Traguardi distinti",
"categories": "Categorie",
"colLevel": "Livello",
"colRewardType": "Tipo di ricompensa",
"colRewardAmount": "Quantità della ricompensa",
"colPoints": "Punti"
},
"calendar": {
"title": "Campagne calendario",
@@ -2032,8 +2223,32 @@
"days": "Giorni",
"dayLabel": "Giorno {day}",
"reward": "Ricompensa",
"noDays": "Nessun giorno configurato per questa campagna"
}
"noDays": "Nessun giorno configurato per questa campagna",
"campaignMeta": "#{id} · {days} giorni · inizio {date}",
"disabled": "Disattivata",
"enabled": "Attiva",
"locksExpired": "Giorni scaduti bloccati",
"rewards": "Ricompense",
"noRewards": "Nessuna ricompensa configurata.",
"colId": "ID",
"colProduct": "Prodotto",
"colItem": "Furni",
"colCredits": "Crediti",
"colPixels": "Duckets",
"colPoints": "Punti",
"colBadge": "Distintivo",
"colSubscription": "Abbonamento",
"typeLabel": "Tipo {type}",
"rewardCount": "{count} ricompense"
},
"campaigns": "Campagne",
"enabled": "Campagne attive",
"totalRewards": "Ricompense totali",
"statusEnabled": "Attiva",
"statusDisabled": "Disattivata",
"campaignMeta": "#{id} · {days} giorni · {rewards} ricompense",
"startsAt": "Inizio: {date}",
"locksExpired": "Giorni scaduti bloccati"
},
"radio": {
"title": "Moderazione radio",
@@ -2066,7 +2281,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: raggiungibile · WARN: non configurato · ERROR: richiesta non riuscita o scaduta.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Aggiorna"
@@ -2125,7 +2340,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "Questo è il lettore audio mostrato ai visitatori. Avvia la riproduzione per verificare che lo streaming sia raggiungibile.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2232,7 +2447,7 @@
"pointsForm": {
"title": "Configurazione punti ascoltatore",
"subtitle": "Reward listeners with currency for time spent on the radio. Edits write to website_settings (radio_points_* keys) and bust the runtime settings cache. Booleans use 0 / 1.",
"saved": "Saved",
"saved": "Impostazioni salvate.",
"points": "Points",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -2462,13 +2677,22 @@
"valuePlaceholder": "Inserisci valore…",
"save": "Salva modifiche",
"cancel": "Annulla"
}
},
"settingsKeys": "Impostazioni",
"textKeys": "Voci di testo",
"keysCount": "{count} chiavi",
"addOverwriteSetting": "Aggiungi o aggiorna un’impostazione",
"key": "Chiave",
"value": "Valore",
"showingFirst": "Prime {count} voci mostrate",
"addOverwriteText": "Aggiungi o aggiorna un testo",
"truncatedHint": "Mostrate {shown} voci su {total}."
},
"alerts": {
"title": "Avvisi",
"subtitle": "Invia avvisi a tutto l'hotel e visualizza avvisi recenti",
"sendAlert": "Invia avviso hotel",
"recentAlerts": "Avvisi recenti",
"recentAlerts": "Avvisi recenti ({count})",
"noRecentAlerts": "Nessun avviso recente",
"form": {
"title": "Titolo avviso",
@@ -2603,7 +2827,25 @@
"save": "Salva impostazioni",
"saving": "Salvataggio…",
"confirmEnable": "Attivare la manutenzione impedirà a tutti gli utenti non dello staff di accedere all'hotel. Continuare?",
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?"
"confirmDisable": "Disattivare la modalità manutenzione e ripristinare l'accesso per tutti gli utenti?",
"maintenance": "Manutenzione",
"hotelLockedDown": "Accesso all’hotel limitato.",
"hotelOpen": "L’hotel è aperto.",
"minLoginRank": "Grado minimo per accedere",
"duringMaintenance": "Durante la manutenzione",
"splashMessage": "Messaggio di manutenzione",
"set": "Configurato",
"empty": "Vuoto",
"currentState": "Stato attuale",
"maintenanceCurrently": "Manutenzione: {status}",
"onlyRankCanLogin": "Possono accedere solo gli utenti con grado {rank} o superiore.",
"hotelOpenToEveryone": "Tutti gli utenti abilitati possono accedere.",
"settings": "Impostazioni",
"enableMaintenance": "Attiva la modalità manutenzione",
"minLoginRankLabel": "Grado minimo consentito durante la manutenzione",
"maintenanceMessageLabel": "Messaggio mostrato ai visitatori",
"maintenanceMessagePlaceholder": "Spiega perché l’hotel non è disponibile.",
"saveChanges": "Salva modifiche"
},
"housekeeping": {
"title": "Archivio permessi",
@@ -2679,7 +2921,7 @@
"tabTest": "Test",
"tabAudit": "Log di audit",
"importPermissions": "Importa Permessi",
"importHint": "Paste a JSON array of Permesso objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Incolla un array JSON di permessi con i campi permission, minRank, description, groupName e dependsOn.",
"importPlaceholder": "Paste JSON here…",
"import": "Importa",
"exportTitle": "Export Permessi",
@@ -2804,8 +3046,13 @@
"variablesHint": "{username}, {email}, {hotel_name}",
"save": "Salva template",
"saving": "Salvataggio…",
"cancel": "Annulla"
}
"cancel": "Annulla",
"variablesPlaceholder": "Variabili separate da virgole",
"active": "Attivo"
},
"create": "Crea modello",
"save": "Salva modello",
"updated": "Aggiornato: {date}"
},
"theme": {
"title": "Tema",
@@ -2816,7 +3063,7 @@
"presetsTitle": "Preimpostazioni tema",
"presetsSubtitle": "Scegli una combinazione di colori predefinita",
"applyPreset": "Applica",
"presetApplied": "Preimpostazione applicata",
"presetApplied": "Preset applicato: {preset}",
"customiseTitle": "Colori personalizzati",
"primary": "Primario",
"secondary": "Secondario",
@@ -2832,7 +3079,11 @@
"saveCss": "Salva CSS",
"savingCss": "Salvataggio…",
"reset": "Ripristina valori predefiniti",
"confirmReset": "Ripristinare il tema ai valori predefiniti?"
"confirmReset": "Ripristinare il tema ai valori predefiniti?",
"saved": "Impostazioni del tema salvate.",
"typographyLayout": "Tipografia e impaginazione",
"font": "Carattere",
"borderRadius": "Raggio degli angoli"
},
"media": {
"title": "Libreria media",
@@ -2965,7 +3216,10 @@
"errors": "Errori",
"alerts": "Alert",
"toolsGroup": "Strumenti",
"ticketInbox": "Inbox"
"ticketInbox": "Inbox",
"cmsErrors": "Errori CMS",
"themeBuilder": "Editor del tema",
"studio": "Studio"
},
"events": "Eventi",
"eventsSubtitle": "Eventi hotel e tipi evento",
@@ -3088,6 +3342,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "Traduzioni del CMS",
"description": "Modifica i testi del sito e dell’housekeeping. Le modifiche salvate vengono applicate al caricamento successivo della pagina e restano dopo i deploy.",
"export": "Esporta JSON",
"saving": "Salvataggio…",
"saveLocale": "Salva {locale}",
"saved": "Traduzioni salvate e applicate.",
"readOnly": "Puoi consultare ed esportare le traduzioni. Per modificarle serve il permesso di modifica delle impostazioni.",
"language": "Lingua",
"coverage": "{total} chiavi · {missing} mancanti · {identical} identiche all’inglese",
"search": "Cerca",
"searchPlaceholder": "Chiave, traduzione o testo inglese…",
"group": "Gruppo",
"allGroups": "Tutti i gruppi",
"status": "Mostra",
"all": "Tutti i testi",
"missing": "Traduzioni mancanti",
"identical": "Identiche all’inglese",
"modified": "Modifiche non salvate",
"fallbackHint": "Le traduzioni mancanti usano l’inglese. Un testo identico può essere intenzionale. Mantieni invariati i nomi delle variabili tra parentesi graffe.",
"unsaved": "{count} modifiche non salvate in questa lingua",
"discard": "Annulla modifiche",
"discardConfirm": "Annullare le modifiche non salvate in questa lingua?",
"fixErrors": "Correggi {count} messaggi non validi prima di salvare.",
"noResults": "Nessun testo corrisponde ai filtri.",
"source": "Riferimento inglese",
"missingPlaceholder": "Traduzione mancante: viene usato l’inglese.",
"page": "Pagina {page} di {total} · {count} chiavi",
"previous": "Precedente",
"next": "Successiva",
"leaveWarning": "Ci sono traduzioni non salvate. Vuoi uscire dalla pagina?",
"conflict": "Queste traduzioni sono cambiate dopo l’apertura dell’editor. Esporta le modifiche e ricarica la pagina prima di salvare.",
"unknownKey": "Chiave di traduzione sconosciuta: {key}",
"empty": "Inserisci una traduzione per {key} oppure annulla questa modifica.",
"invalid": "Sintassi del messaggio non valida in {key}. Controlla le parentesi graffe e i tag del testo.",
"variables": "Mantieni gli stessi nomi delle variabili e tag del testo inglese in {key}."
}
},
"analytics": {
@@ -3221,6 +3512,104 @@
"modNavInbox": "Inbox",
"modNavDesk": "Supporto CMS",
"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": {
+56 -5
View File
@@ -1123,7 +1123,21 @@
"saving": "Opslaan…",
"deleteArticle": "Artikel verwijderen",
"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": {
@@ -2045,7 +2059,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -2222,7 +2236,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: bereikbaar · WARN: niet ingesteld · ERROR: aanvraag mislukt of time-out.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Vernieuwen"
@@ -2297,7 +2311,7 @@
"title": "Meldingen",
"subtitle": "Hotelbrede meldingen verzenden en recente meldingen bekijken",
"sendAlert": "Hotelmelding verzenden",
"recentAlerts": "Recente meldingen",
"recentAlerts": "Recente meldingen ({count})",
"noRecentAlerts": "Geen recente meldingen",
"form": {
"title": "Melding titel",
@@ -2516,7 +2530,7 @@
"tabTest": "Test",
"tabAudit": "Auditlog",
"importPermissions": "Permissies importeren",
"importHint": "Plak een JSON-array met permissie-objecten. Formaat: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Plak een JSON-array met permissie-objecten. Formaat: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Plak JSON hier…",
"import": "Importeren",
"exportTitle": "Permissies exporteren",
@@ -2774,6 +2788,43 @@
"subtitle": "User-facing strings from emulator_settings (commands, wordfilter, pet/matchmaking messages).",
"infoBox": "Configuration settings (ports, timeouts, IDs) do not appear here — use the full settings link. Saved changes are synced to the emulator via RCON.",
"fullSettings": "Full settings"
},
"cms": {
"title": "CMS-vertalingen",
"description": "Bewerk de teksten op de website en in housekeeping. Opgeslagen wijzigingen worden bij de volgende paginalading toegepast en blijven behouden na deployments.",
"export": "JSON exporteren",
"saving": "Opslaan…",
"saveLocale": "{locale} opslaan",
"saved": "Vertalingen opgeslagen en toegepast.",
"readOnly": "Je kunt vertalingen bekijken en exporteren. Bewerken vereist toestemming om instellingen te wijzigen.",
"language": "Taal",
"coverage": "{total} sleutels · {missing} ontbreken · {identical} gelijk aan Engels",
"search": "Zoeken",
"searchPlaceholder": "Sleutel, vertaling of Engelse brontekst…",
"group": "Groep",
"allGroups": "Alle groepen",
"status": "Tonen",
"all": "Alle teksten",
"missing": "Ontbrekende vertalingen",
"identical": "Gelijk aan Engels",
"modified": "Niet-opgeslagen wijzigingen",
"fallbackHint": "Ontbrekende vertalingen gebruiken Engels. Identieke tekst kan bewust zijn gekozen. Laat de namen van variabelen tussen accolades ongewijzigd.",
"unsaved": "{count} niet-opgeslagen wijzigingen in deze taal",
"discard": "Wijzigingen verwerpen",
"discardConfirm": "Niet-opgeslagen wijzigingen in deze taal verwerpen?",
"fixErrors": "Corrigeer {count} ongeldige teksten voordat je opslaat.",
"noResults": "Geen teksten gevonden met deze filters.",
"source": "Engelse brontekst",
"missingPlaceholder": "Vertaling ontbreekt; Engels wordt gebruikt.",
"page": "Pagina {page} van {total} · {count} sleutels",
"previous": "Vorige",
"next": "Volgende",
"leaveWarning": "Je hebt niet-opgeslagen vertalingen. Deze pagina verlaten?",
"conflict": "Deze vertalingen zijn gewijzigd sinds je de editor opende. Exporteer je wijzigingen en herlaad de pagina voordat je opslaat.",
"unknownKey": "Onbekende vertaalsleutel: {key}",
"empty": "Vul een vertaling in voor {key} of verwerp deze wijziging.",
"invalid": "Ongeldige berichtsyntaxis in {key}. Controleer accolades en tekstopmaaktags.",
"variables": "Behoud dezelfde variabelenamen en tekstopmaaktags als de Engelse bron in {key}."
}
},
"hubs": {
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "ingen API-sett som nå spilles",
"noListenersHint": "ingen lyttere API satt",
"endpointDiagnostics": "Endepunktdiagnostikk",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> tilgjengelig · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">ADVARSEL</span> ikke konfigurert · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(>-farge mislyktes] / ER-farge mislyktes ELLER.",
"diagnosticsLegend": "OK: tilgjengelig · WARN: ikke konfigurert · ERROR: forespørselen mislyktes eller fikk tidsavbrudd.",
"streamUrl": "Strøm URL",
"unreachable": "uoppnåelig",
"refresh": "Oppdater"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Det er ingen radio_stream_url satt, så det er ingenting å bygge inn ennå.",
"goToSettings": "Gå til Radioinnstillinger",
"livePreview": "Live forhåndsvisning",
"livePreviewText": "Dette er den nakne <audio>-spilleren slik besøkende vil se den. Trykk på play for å bekrefte at strømmen er tilgjengelig.",
"livePreviewText": "Dette er den nakne audio-spilleren slik besøkende vil se den. Trykk på play for å bekrefte at strømmen er tilgjengelig.",
"source": "Kilde",
"audioSnippet": "Utdrag av lydspiller",
"audioSnippetDesc": "Drop-in HTML5-lydspiller pekte rett på strømmen. Fungerer overalt hvor vanlig HTML er tillatt.",
@@ -1998,7 +1998,7 @@
"title": "Varsler",
"subtitle": "Send varsler for hele hotellet og se nylige varsler",
"sendAlert": "Send hotellvarsel",
"recentAlerts": "Nylige varsler",
"recentAlerts": "Nylige varsler ({count})",
"noRecentAlerts": "Ingen nylige varsler",
"form": {
"title": "Varseltittel",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importer Tillatelser",
"importHint": "Paste a JSON array of Tillatelse objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tillatelse objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importer",
"exportTitle": "Export Tillatelser",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Forhåndsinnstillinger for tema",
"presetsSubtitle": "Velg et forhåndsbygd fargevalg",
"applyPreset": "Søk",
"presetApplied": "Forhåndsinnstilling brukt",
"presetApplied": "Forhåndsinnstilling brukt: {preset}",
"customiseTitle": "Egendefinerte farger",
"primary": "Primær",
"secondary": "Sekundær",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "brak aktualnie odtwarzanego zestawu API",
"noListenersHint": "brak zestawu API odbiorników",
"endpointDiagnostics": "Diagnostyka punktów końcowych",
"diagnosticsLegend": "<span class=\"inline-block tekst-[0.72rem] czcionka-bold px-2 py-0.5 zaokrąglona-pełna bg-green-600/20 tekst-[var(--color-text)]\">OK</span> osiągalna · <span class=\"inline-block tekst-[0.72rem] czcionka-bold px-2 py-0.5 zaokrąglona-pełna bg-[var(--color-primary)]/18 tekst-[var(--color-text)]\">WARN</span> nie skonfigurowano · <span class=\"inline-block text-[0.72rem] Font-bold px-2 py-0.5 zaokrąglony-pełny bg-red-500/22 tekst-[var(--color-text)]\">BŁĄD</span> pobieranie nie powiodło się / przekroczyło czas na zewnątrz.",
"diagnosticsLegend": "OK: dostępne · WARN: nieskonfigurowane · ERROR: błąd żądania lub przekroczony limit czasu.",
"streamUrl": "Adres URL strumienia",
"unreachable": "nieosiągalny",
"refresh": "Odśwież"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Nie ma ustawionego radio_stream_url, więc nie ma jeszcze czego osadzać.",
"goToSettings": "Przejdź do Ustawień radia",
"livePreview": "Podgląd na żywo",
"livePreviewText": "To jest goły odtwarzacz <audio>, jaki zobaczą odwiedzający. Naciśnij przycisk odtwarzania, aby potwierdzić, że strumień jest osiągalny.",
"livePreviewText": "To jest goły odtwarzacz audio, jaki zobaczą odwiedzający. Naciśnij przycisk odtwarzania, aby potwierdzić, że strumień jest osiągalny.",
"source": "Źródło",
"audioSnippet": "Fragment odtwarzacza audio",
"audioSnippetDesc": "Wpuszczany odtwarzacz audio HTML5 skierowany bezpośrednio na strumień. Działa wszędzie tam, gdzie dozwolony jest zwykły HTML.",
@@ -1999,7 +1999,7 @@
"title": "Alerty",
"subtitle": "Wysyłaj alerty dotyczące całego hotelu i przeglądaj najnowsze alerty",
"sendAlert": "Wyślij alert hotelowy",
"recentAlerts": "Ostatnie alerty",
"recentAlerts": "Ostatnie alerty ({count})",
"noRecentAlerts": "Brak ostatnich alertów",
"form": {
"title": "Tytuł alertu",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importuj Uprawnienia",
"importHint": "Paste a JSON array of Pozwolenie objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Pozwolenie objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importuj",
"exportTitle": "Export Uprawnienia",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Wstępne ustawienia motywu",
"presetsSubtitle": "Wybierz gotowy schemat kolorów",
"applyPreset": "Zastosuj",
"presetApplied": "Zastosowano ustawienie wstępne",
"presetApplied": "Zastosowano ustawienie wstępne: {preset}",
"customiseTitle": "Kolory niestandardowe",
"primary": "Podstawowy",
"secondary": "Drugorzędne",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "nenhum conjunto de API em execução",
"noListenersHint": "nenhuma API de ouvinte definida",
"endpointDiagnostics": "Diagnóstico de endpoint",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 round-full bg-green-600/20 text-[var(--color-text)]\">OK</span> alcançável · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 arredondado-completo bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> não configurado · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 round-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> busca falhou/expirou o tempo limite.",
"diagnosticsLegend": "OK: acessível · WARN: não configurado · ERROR: falha no pedido ou tempo esgotado.",
"streamUrl": "URL de transmissão",
"unreachable": "inacessível",
"refresh": "Atualizar"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Não há radio_stream_url definido, então não há nada para incorporar ainda.",
"goToSettings": "Vá para configurações de rádio",
"livePreview": "Visualização ao vivo",
"livePreviewText": "Este é o player <audio> simples, como os visitantes o verão. Pressione play para confirmar que o stream está acessível.",
"livePreviewText": "Este é o player audio simples, como os visitantes o verão. Pressione play para confirmar que o stream está acessível.",
"source": "Fonte",
"audioSnippet": "Snippet do player de áudio",
"audioSnippetDesc": "Reprodutor de áudio HTML5 drop-in apontado diretamente para o stream. Funciona em qualquer lugar onde HTML simples seja permitido.",
@@ -1999,7 +1999,7 @@
"title": "Alertas",
"subtitle": "Envie alertas para todo o hotel e visualize alertas recentes",
"sendAlert": "Enviar alerta de hotel",
"recentAlerts": "Alertas recentes",
"recentAlerts": "Alertas recentes ({count})",
"noRecentAlerts": "Nenhum alerta recente",
"form": {
"title": "Título do alerta",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importar Permissões",
"importHint": "Paste a JSON array of Permissão objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permissão objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importar",
"exportTitle": "Export Permissões",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Predefinições de tema",
"presetsSubtitle": "Escolha um esquema de cores pré-construído",
"applyPreset": "Aplicar",
"presetApplied": "Predefinição aplicada",
"presetApplied": "Predefinição aplicada: {preset}",
"customiseTitle": "Cores personalizadas",
"primary": "Primário",
"secondary": "Secundário",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nici un set API care se joacă acum",
"noListenersHint": "nu se setează API pentru ascultători",
"endpointDiagnostics": "Diagnosticarea punctului final",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> accesibil · <span class=\"inline-block text-[0.72rem] font-bold-full-pxl.5 bg-[var(--color-primary)]/18 text-[var(--color-text)]\">AVERTISMENT</span> neconfigurat · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(]> text-[var](---> text-[var](--) eșuat-[var (---> text-[var](--)",
"diagnosticsLegend": "OK: accesibil · WARN: neconfigurat · ERROR: cerere eșuată sau timp expirat.",
"streamUrl": "Adresa URL a fluxului",
"unreachable": "de neatins",
"refresh": "Reîmprospătați"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nu există nicio setare radio_stream_url, așa că încă nu există nimic de încorporat.",
"goToSettings": "Accesați Setările radio",
"livePreview": "Previzualizare live",
"livePreviewText": "Acesta este playerul <audio> gol, așa cum îl vor vedea vizitatorii. Apăsați pe redare pentru a confirma că fluxul este accesibil.",
"livePreviewText": "Acesta este playerul audio gol, așa cum îl vor vedea vizitatorii. Apăsați pe redare pentru a confirma că fluxul este accesibil.",
"source": "Sursa",
"audioSnippet": "Fragment de player audio",
"audioSnippetDesc": "Playerul audio HTML5 integrat a îndreptat direct spre flux. Funcționează oriunde este permis HTML simplu.",
@@ -1998,7 +1998,7 @@
"title": "Alerte",
"subtitle": "Trimiteți alerte la nivel de hotel și vizualizați alertele recente",
"sendAlert": "Trimiteți o alertă la hotel",
"recentAlerts": "Alerte recente",
"recentAlerts": "Alerte recente ({count})",
"noRecentAlerts": "Nu există alerte recente",
"form": {
"title": "Titlul alertei",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Import Permisiuni",
"importHint": "Paste a JSON array of Permisiune objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Permisiune objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Import",
"exportTitle": "Export Permisiuni",
@@ -2346,7 +2346,7 @@
"presetsTitle": "presetări teme",
"presetsSubtitle": "Alegeți o schemă de culori prefabricată",
"applyPreset": "Aplicați",
"presetApplied": "Presetarea aplicată",
"presetApplied": "Presetarea aplicată: {preset}",
"customiseTitle": "Culori personalizate",
"primary": "Primar",
"secondary": "Secundar",
+5 -5
View File
@@ -1707,7 +1707,7 @@
"noNowPlayingHint": "no now-playing API set",
"noListenersHint": "no listeners API set",
"endpointDiagnostics": "Endpoint diagnostics",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> reachable · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> not configured · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> fetch failed / timed out.",
"diagnosticsLegend": "OK: доступно · WARN: не настроено · ERROR: ошибка запроса или истекло время ожидания.",
"streamUrl": "Stream URL",
"unreachable": "unreachable",
"refresh": "Refresh"
@@ -1766,7 +1766,7 @@
"noStreamUrlText": "There is no radio_stream_url set, so there is nothing to embed yet.",
"goToSettings": "Go to Radio Settings",
"livePreview": "Live preview",
"livePreviewText": "This is the bare <audio> player as visitors will see it. Press play to confirm the stream is reachable.",
"livePreviewText": "This is the bare audio player as visitors will see it. Press play to confirm the stream is reachable.",
"source": "Source",
"audioSnippet": "Audio player snippet",
"audioSnippetDesc": "Drop-in HTML5 audio player pointed straight at the stream. Works anywhere plain HTML is allowed.",
@@ -1997,7 +1997,7 @@
"title": "Alerts",
"subtitle": "Send hotel-wide alerts and view recent alerts",
"sendAlert": "Send hotel alert",
"recentAlerts": "Recent alerts",
"recentAlerts": "Recent alerts ({count})",
"noRecentAlerts": "No recent alerts",
"form": {
"title": "Alert title",
@@ -2208,7 +2208,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Импорт Разрешения",
"importHint": "Paste a JSON array of permission objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of permission objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Импорт",
"exportTitle": "Export Разрешения",
@@ -2345,7 +2345,7 @@
"presetsTitle": "Theme presets",
"presetsSubtitle": "Choose a pre-built colour scheme",
"applyPreset": "Apply",
"presetApplied": "Preset applied",
"presetApplied": "Preset applied: {preset}",
"customiseTitle": "Custom colours",
"primary": "Primary",
"secondary": "Secondary",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "nie je teraz nastavené rozhranie API",
"noListenersHint": "nie je nastavené žiadne rozhranie API poslucháčov",
"endpointDiagnostics": "Diagnostika koncového bodu",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> dosiahnuteľné · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-primaryl]-1 bg-8 rounded text-[var(--color-text)]\">UPOZORNENIE</span> nie je nakonfigurované · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">CHYBA</span> načítanie zlyhalo / vypršal časový limit.",
"diagnosticsLegend": "OK: dostupné · WARN: nenastavené · ERROR: požiadavka zlyhala alebo vypršal časový limit.",
"streamUrl": "Webová adresa streamu",
"unreachable": "nedosiahnuteľný",
"refresh": "Obnoviť"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Nie je nastavená žiadna adresa_url rádia, takže zatiaľ nie je čo vkladať.",
"goToSettings": "Prejdite do časti Nastavenia rádia",
"livePreview": "Živá ukážka",
"livePreviewText": "Toto je holý <audio> prehrávač, ako ho uvidia návštevníci. Stlačením tlačidla prehrávania potvrďte, že stream je dostupný.",
"livePreviewText": "Toto je holý audio prehrávač, ako ho uvidia návštevníci. Stlačením tlačidla prehrávania potvrďte, že stream je dostupný.",
"source": "Zdroj",
"audioSnippet": "Útržok zvukového prehrávača",
"audioSnippetDesc": "Zvukový prehrávač HTML5 nasmerovaný priamo na stream. Funguje všade, kde je povolený obyčajný HTML.",
@@ -1998,7 +1998,7 @@
"title": "Upozornenia",
"subtitle": "Odosielajte upozornenia pre celý hotel a zobrazujte posledné upozornenia",
"sendAlert": "Odoslať upozornenie na hotel",
"recentAlerts": "Nedávne upozornenia",
"recentAlerts": "Nedávne upozornenia ({count})",
"noRecentAlerts": "Žiadne nedávne upozornenia",
"form": {
"title": "Názov upozornenia",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importovať Povolenia",
"importHint": "Paste a JSON array of Povolenie objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Povolenie objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importovať",
"exportTitle": "Export Povolenia",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Prednastavenia tém",
"presetsSubtitle": "Vyberte si vopred zostavenú farebnú schému",
"applyPreset": "Použiť",
"presetApplied": "Predvoľba bola použitá",
"presetApplied": "Predvoľba bola použitá: {preset}",
"customiseTitle": "Vlastné farby",
"primary": "Primárne",
"secondary": "Sekundárne",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "нема скупа АПИ-ја који се тренутно репродукује",
"noListenersHint": "није постављен АПИ слушалаца",
"endpointDiagnostics": "Ендпоинт диагностицс",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">ОК</span> доступан · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">УПОЗОРЕЊЕ</span> није конфигурисано · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> преузимање није успело / време је истекло.",
"diagnosticsLegend": "OK: доступно · WARN: није подешено · ERROR: захтев није успео или је истекао.",
"streamUrl": "УРЛ стрима",
"unreachable": "недостижан",
"refresh": "Освежи"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Радио_стреам_урл није постављен, тако да још нема шта да се угради.",
"goToSettings": "Идите на Подешавања радија",
"livePreview": "Преглед уживо",
"livePreviewText": "Ово је голи <audio> плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
"livePreviewText": "Ово је голи audio плејер како ће га посетиоци видети. Притисните плаи да бисте потврдили да је стрим доступан.",
"source": "Извор",
"audioSnippet": "Исечак аудио плејера",
"audioSnippetDesc": "Улазни ХТМЛ5 аудио плејер усмерен право на ток. Ради свуда где је обичан ХТМЛ дозвољен.",
@@ -1998,7 +1998,7 @@
"title": "Алертс",
"subtitle": "Шаљите упозорења широм хотела и погледајте недавна упозорења",
"sendAlert": "Пошаљите хотелско обавештење",
"recentAlerts": "Недавна упозорења",
"recentAlerts": "Недавна упозорења ({count})",
"noRecentAlerts": "Нема недавних упозорења",
"form": {
"title": "Наслов упозорења",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Увоз Дозволе",
"importHint": "Paste a JSON array of Дозвола objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Дозвола objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Увоз",
"exportTitle": "Export Дозволе",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Унапред подешене теме",
"presetsSubtitle": "Choose a pre-built colour scheme",
"applyPreset": "Пријавите се",
"presetApplied": "Пресет је примењен",
"presetApplied": "Пресет је примењен: {preset}",
"customiseTitle": "Прилагођене боје",
"primary": "Примарни",
"secondary": "Секундарни",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "ingen nu-spelande API-uppsättning",
"noListenersHint": "ingen lyssnars API inställd",
"endpointDiagnostics": "Slutpunktsdiagnostik",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> nås · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-full. bg-[var(--color-primary)]/18 text-[var(--color-text)]\">VARNING</span> inte konfigurerad · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text) /hämtad tid utebliven text-[var(--färg-R-text])",
"diagnosticsLegend": "OK: tillgänglig · WARN: inte konfigurerad · ERROR: begäran misslyckades eller tog för lång tid.",
"streamUrl": "Streama URL",
"unreachable": "oåtkomlig",
"refresh": "Uppdatera"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Det finns ingen radio_stream_url inställd, så det finns inget att bädda in ännu.",
"goToSettings": "Gå till Radioinställningar",
"livePreview": "Live förhandsvisning",
"livePreviewText": "Detta är den blotta <audio>-spelaren som besökare kommer att se den. Tryck på play för att bekräfta att streamen är tillgänglig.",
"livePreviewText": "Detta är den blotta audio-spelaren som besökare kommer att se den. Tryck på play för att bekräfta att streamen är tillgänglig.",
"source": "Källa",
"audioSnippet": "Utdrag från ljudspelare",
"audioSnippetDesc": "Drop-in HTML5-ljudspelare pekade rakt mot strömmen. Fungerar överallt där vanlig HTML är tillåtet.",
@@ -1998,7 +1998,7 @@
"title": "Varningar",
"subtitle": "Skicka varningar för hela hotellet och visa de senaste varningarna",
"sendAlert": "Skicka hotellvarning",
"recentAlerts": "Senaste varningar",
"recentAlerts": "Senaste varningar ({count})",
"noRecentAlerts": "Inga senaste varningar",
"form": {
"title": "Varningsrubrik",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Importera Behörigheter",
"importHint": "Paste a JSON array of Tillstånd objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Tillstånd objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Importera",
"exportTitle": "Export Behörigheter",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Förinställda tema",
"presetsSubtitle": "Välj ett förbyggt färgschema",
"applyPreset": "Ansök",
"presetApplied": "Förinställning tillämpas",
"presetApplied": "Förinställning tillämpas: {preset}",
"customiseTitle": "Anpassade färger",
"primary": "Primär",
"secondary": "Sekundär",
+5 -5
View File
@@ -1709,7 +1709,7 @@
"noNowPlayingHint": "şu anda oynatılan API seti yok",
"noListenersHint": "dinleyici API seti yok",
"endpointDiagnostics": "Uç nokta teşhisi",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">Tamam</span> ulaşılabilir · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> yapılandırılmadı · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ERROR</span> getirme başarısız oldu / zaman aşımına uğradı.",
"diagnosticsLegend": "OK: erişilebilir · WARN: yapılandırılmamış · ERROR: istek başarısız veya zaman aşımına uğradı.",
"streamUrl": "Akış URL'si",
"unreachable": "ulaşılamaz",
"refresh": "Yenile"
@@ -1768,7 +1768,7 @@
"noStreamUrlText": "Radio_stream_url kümesi yok, bu nedenle henüz eklenecek bir şey yok.",
"goToSettings": "Radyo Ayarlarına Git",
"livePreview": "Canlı önizleme",
"livePreviewText": "Bu, ziyaretçilerin göreceği şekliyle çıplak <audio> oynatıcısıdır. Akışın erişilebilir olduğunu onaylamak için oynat tuşuna basın.",
"livePreviewText": "Bu, ziyaretçilerin göreceği şekliyle çıplak audio oynatıcısıdır. Akışın erişilebilir olduğunu onaylamak için oynat tuşuna basın.",
"source": "Kaynak",
"audioSnippet": "Ses oynatıcı snippet'i",
"audioSnippetDesc": "Açılan HTML5 ses oynatıcısı doğrudan akışa işaret ediyordu. Düz HTML'ye izin verilen her yerde çalışır.",
@@ -1999,7 +1999,7 @@
"title": "Uyarılar",
"subtitle": "Otel genelinde uyarılar gönderin ve son uyarıları görüntüleyin",
"sendAlert": "Otel uyarısı gönder",
"recentAlerts": "Son uyarılar",
"recentAlerts": "Son uyarılar ({count})",
"noRecentAlerts": "Yeni uyarı yok",
"form": {
"title": "Uyarı başlığı",
@@ -2210,7 +2210,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "İthalat İzinler",
"importHint": "Paste a JSON array of İzin objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of İzin objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "İthalat",
"exportTitle": "Export İzinler",
@@ -2347,7 +2347,7 @@
"presetsTitle": "Tema ön ayarları",
"presetsSubtitle": "Önceden oluşturulmuş bir renk şeması seçin",
"applyPreset": "Uygula",
"presetApplied": "Ön ayar uygulandı",
"presetApplied": "Ön ayar uygulandı: {preset}",
"customiseTitle": "Özel renkler",
"primary": "Birincil",
"secondary": "İkincil",
+5 -5
View File
@@ -1708,7 +1708,7 @@
"noNowPlayingHint": "не налаштовано API поточного відтворення",
"noListenersHint": "API слухачів не встановлено",
"endpointDiagnostics": "Діагностика кінцевої точки",
"diagnosticsLegend": "<span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]\">OK</span> доступно · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--color-primary)]/18 text-[var(--color-text)]\">WARN</span> не налаштовано · <span class=\"inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-red-500/22 text-[var(--color-text)]\">ПОМИЛКА</span> вибірка не вдалася / час очікування минув.",
"diagnosticsLegend": "OK: доступно · WARN: не налаштовано · ERROR: помилка запиту або вичерпано час очікування.",
"streamUrl": "URL-адреса потоку",
"unreachable": "недоступний",
"refresh": "Оновити"
@@ -1767,7 +1767,7 @@
"noStreamUrlText": "Немає параметра radio_stream_url, тому поки що немає чого вставляти.",
"goToSettings": "Перейдіть до налаштувань радіо",
"livePreview": "Попередній перегляд",
"livePreviewText": "Це простий програвач <audio>, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
"livePreviewText": "Це простий програвач audio, як його побачать відвідувачі. Натисніть кнопку відтворення, щоб підтвердити, що потік доступний.",
"source": "Джерело",
"audioSnippet": "Фрагмент аудіоплеєра",
"audioSnippetDesc": "Вставний аудіопрогравач HTML5 спрямований прямо на потік. Працює скрізь, де дозволено простий HTML.",
@@ -1998,7 +1998,7 @@
"title": "Оповіщення",
"subtitle": "Надсилайте сповіщення для всього готелю та переглядайте останні сповіщення",
"sendAlert": "Надіслати сповіщення готелю",
"recentAlerts": "Останні сповіщення",
"recentAlerts": "Останні сповіщення ({count})",
"noRecentAlerts": "Немає останніх сповіщень",
"form": {
"title": "Назва сповіщення",
@@ -2209,7 +2209,7 @@
"tabTest": "Test",
"tabAudit": "Audit log",
"importPermissions": "Імпорт Дозволи",
"importHint": "Paste a JSON array of Дозвіл objects. Format: [{ \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" }]",
"importHint": "Paste a JSON array of Дозвіл objects. Format: ['{' \"permission\": \"...\", \"minRank\": 7, \"description\": \"...\", \"groupName\": \"...\", \"dependsOn\": \"...\" '}']",
"importPlaceholder": "Paste JSON here…",
"import": "Імпорт",
"exportTitle": "Export Дозволи",
@@ -2346,7 +2346,7 @@
"presetsTitle": "Попередні налаштування теми",
"presetsSubtitle": "Виберіть попередньо створену колірну схему",
"applyPreset": "Застосувати",
"presetApplied": "Попереднє налаштування застосовано",
"presetApplied": "Попереднє налаштування застосовано: {preset}",
"customiseTitle": "Нестандартні кольори",
"primary": "Первинний",
"secondary": "Вторинний",