Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
This commit is contained in:
commit
fea8023ac6
87 files changed
+3760
-1105
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[][],
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
log: vi.fn(() => "news-error-1"),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||
vi.mock("@/lib/services/news-cache", () => ({
|
||||
invalidateNewsCache: state.invalidate,
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string, args?: { reference: string }) =>
|
||||
key + (args?.reference ?? ""),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
update: () => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(value);
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries({
|
||||
title: "Test news",
|
||||
slug: "test-news",
|
||||
shortStory: "Summary",
|
||||
fullStory: "Body",
|
||||
status: "published",
|
||||
...extra,
|
||||
}))
|
||||
f.set(k, v);
|
||||
return f;
|
||||
}
|
||||
describe("news saves", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.rows = [];
|
||||
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||
});
|
||||
it("preserves a recoverable error and logs its reference", async () => {
|
||||
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
|
||||
const result = await createArticle(form());
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "saveFailedReferencenews-error-1",
|
||||
});
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("saves drafts without publication notifications", async () => {
|
||||
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(state.notify).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slug: "test-news",
|
||||
publishAt: null,
|
||||
status: "published",
|
||||
}),
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+98
-103
@@ -1,9 +1,15 @@
|
||||
"use server";
|
||||
|
||||
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
@@ -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 }) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { count, eq, ne } from "drizzle-orm";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
|
||||
export async function AdminWorkQueue() {
|
||||
const [{ session, permissions }, t] = await Promise.all([
|
||||
getAdminContext(),
|
||||
getTranslations("pages.admin.workQueue"),
|
||||
]);
|
||||
const allowed = (permission: string) =>
|
||||
canAccess(permissions, permission, session.user.rank);
|
||||
const entries: { key: string; href: string; load: () => Promise<number> }[] =
|
||||
[];
|
||||
if (allowed(PERMS.TICKETS_VIEW))
|
||||
entries.push({
|
||||
key: "tickets",
|
||||
href: "/admin/tickets",
|
||||
load: async () => {
|
||||
const [cms, help] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed")),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true)),
|
||||
]);
|
||||
return (cms[0]?.total ?? 0) + (help[0]?.total ?? 0);
|
||||
},
|
||||
});
|
||||
if (allowed(PERMS.ASSETS_IMPORT))
|
||||
entries.push({
|
||||
key: "imports",
|
||||
href: "/admin/studio/furni",
|
||||
load: async () =>
|
||||
(await new ImportJobStore().list()).filter(
|
||||
(job) =>
|
||||
job.userId === Number(session.user.id) &&
|
||||
(job.state === "interrupted" ||
|
||||
job.items.some((item) => item.state === "failed")),
|
||||
).length,
|
||||
});
|
||||
if (allowed(PERMS.DEVOPS_VIEW))
|
||||
entries.push({
|
||||
key: "errors",
|
||||
href: "/admin/devops/cms-errors?status=open",
|
||||
load: async () =>
|
||||
new Set(
|
||||
(await new ErrorStore().read()).records
|
||||
.filter((record) => !record.resolved)
|
||||
.map((record) => record.fingerprint),
|
||||
).size,
|
||||
});
|
||||
const cards = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
try {
|
||||
return { ...entry, count: await entry.load() };
|
||||
} catch (error) {
|
||||
logServerError("admin.work_queue_failed", error, { queue: entry.key });
|
||||
return { ...entry, count: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cards.length) return null;
|
||||
return (
|
||||
<section className="mb-6" aria-labelledby="work-queue">
|
||||
<h2
|
||||
id="work-queue"
|
||||
className="mb-3 text-sm font-bold text-[var(--admin-text)]"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
href={card.href}
|
||||
key={card.key}
|
||||
className="admin-card flex items-center gap-4 no-underline focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||
>
|
||||
<span className="text-2xl font-bold tabular-nums text-[var(--admin-text)]">
|
||||
{card.count ?? "—"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<strong className="block text-sm text-[var(--admin-text)]">
|
||||
{t(card.key)}
|
||||
</strong>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{card.count === null ? t("unavailable") : t(`${card.key}Hint`)}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight size={16} aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
/** Protect browser close/reload and same-tab link navigation while editing. */
|
||||
export function useUnsavedChanges(dirty: boolean, message: string) {
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
function click(event: MouseEvent) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
link.hasAttribute("download") ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const target = new URL(link.href, location.href);
|
||||
if (
|
||||
target.origin === location.origin &&
|
||||
target.pathname === location.pathname &&
|
||||
target.search === location.search
|
||||
)
|
||||
return;
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
document.addEventListener("click", click, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
document.removeEventListener("click", click, true);
|
||||
};
|
||||
}, [dirty, message]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
/** Refresh after the prior request settles; pause while the tab is hidden. */
|
||||
export function useVisiblePolling(
|
||||
refresh: () => Promise<unknown>,
|
||||
delay: number,
|
||||
) {
|
||||
const callback = useRef(refresh);
|
||||
callback.current = refresh;
|
||||
useEffect(() => {
|
||||
let stopped = false,
|
||||
running = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function tick() {
|
||||
if (stopped || running) return;
|
||||
clearTimeout(timer);
|
||||
if (document.visibilityState === "hidden") return;
|
||||
running = true;
|
||||
try {
|
||||
await callback.current();
|
||||
} finally {
|
||||
running = false;
|
||||
if (!stopped) timer = setTimeout(tick, delay);
|
||||
}
|
||||
}
|
||||
function visibility() {
|
||||
if (document.visibilityState === "visible") void tick();
|
||||
else clearTimeout(timer);
|
||||
}
|
||||
void tick();
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
}, [delay]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { parse } from "@formatjs/icu-messageformat-parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flattenObject } from "@/lib/translations-utils";
|
||||
|
||||
describe("CMS message catalogs", () => {
|
||||
it("contains valid ICU messages in every shipped locale", async () => {
|
||||
const errors: string[] = [];
|
||||
for (const file of await readdir("src/messages")) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const messages = flattenObject(
|
||||
JSON.parse(await readFile(`src/messages/${file}`, "utf8")),
|
||||
);
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
try {
|
||||
parse(value);
|
||||
} catch {
|
||||
errors.push(`${file}: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
it("provides actual text for the search result headings and dashboard counts", async () => {
|
||||
const { createTranslator } = await import("next-intl");
|
||||
const messages = JSON.parse(await readFile("src/messages/en.json", "utf8"));
|
||||
const errors: unknown[] = [];
|
||||
const t = createTranslator({
|
||||
locale: "en",
|
||||
messages,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
expect(t("pages.admin.dashboard.totalAccounts", { count: 12 })).toContain(
|
||||
"12",
|
||||
);
|
||||
expect(t("pages.admin.search.noResults")).not.toContain("pages.");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createTranslator } from "next-intl";
|
||||
import { expect, it } from "vitest";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import { visibleAdminNavigation } from "@/lib/admin-navigation";
|
||||
import en from "@/messages/en.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
|
||||
it.each([
|
||||
["en", en],
|
||||
["it", itMessages],
|
||||
] as const)(
|
||||
"renders housekeeping navigation and shared controls in %s",
|
||||
(locale, messages) => {
|
||||
const errors: unknown[] = [];
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: messages as unknown as Record<string, string>,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const translate = t as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, string | number>,
|
||||
) => string;
|
||||
const keys = new Set([
|
||||
...ADMIN_NAV_GROUPS.flatMap((g) => [
|
||||
g.labelKey,
|
||||
...g.items.map((i) => i.labelKey),
|
||||
]),
|
||||
...visibleAdminNavigation({
|
||||
allowedPermissions: [],
|
||||
isSuperAdmin: true,
|
||||
navConfig: {},
|
||||
}).map((g) => g.labelKey),
|
||||
]);
|
||||
for (const key of keys) translate(`pages.admin.nav.${key}`);
|
||||
for (const area of ["actions", "table", "cmsErrors", "workQueue"] as const)
|
||||
for (const key of Object.keys(en.pages.admin[area]))
|
||||
translate(`pages.admin.${area}.${key}`, {
|
||||
count: 2,
|
||||
action: "Save",
|
||||
page: 2,
|
||||
last: 7,
|
||||
id: "1",
|
||||
name: "Name",
|
||||
groups: 1,
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
|
||||
export function isSupportedLocale(
|
||||
value: string | undefined,
|
||||
): value is AppLocale {
|
||||
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
+12
-39
@@ -1,41 +1,17 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { 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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||
import { visibleAdminNavigation } from "./admin-navigation";
|
||||
|
||||
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
|
||||
it("retains each route and its permission definition once after regrouping", () => {
|
||||
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
|
||||
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
|
||||
expect(actual).toHaveLength(original.length);
|
||||
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
|
||||
for (const item of original) expect(actual).toContain(item);
|
||||
expect(
|
||||
visibleAdminNavigation(access)
|
||||
.find((g) => g.labelKey === "catalogFurniture")
|
||||
?.items.some((i) => i.href === "/admin/studio"),
|
||||
).toBe(true);
|
||||
});
|
||||
it("respects hidden groups, owner order and ACL in every consumer", () => {
|
||||
const configured = visibleAdminNavigation({
|
||||
...access,
|
||||
navConfig: {
|
||||
groupOrder: ["monitoring"],
|
||||
hiddenGroups: ["economy"],
|
||||
hiddenItems: ["/admin/studio"],
|
||||
},
|
||||
});
|
||||
expect(configured[0].labelKey).toBe("monitoring");
|
||||
expect(
|
||||
configured
|
||||
.flatMap((g) => g.items)
|
||||
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
|
||||
).toBe(false);
|
||||
const limited = visibleAdminNavigation({
|
||||
...access,
|
||||
isSuperAdmin: false,
|
||||
}).flatMap((g) => g.items);
|
||||
expect(limited.every((i) => !i.permission)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Activity,
|
||||
Cog,
|
||||
LayoutDashboard,
|
||||
Newspaper,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
type AdminNavGroup,
|
||||
navItemIsAllowed,
|
||||
} from "@/lib/admin-nav";
|
||||
import {
|
||||
type AdminNavConfig,
|
||||
applyAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
|
||||
export type AdminNavigationOptions = {
|
||||
allowedPermissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
navConfig: AdminNavConfig;
|
||||
};
|
||||
const sections = [
|
||||
{ labelKey: "overview", icon: LayoutDashboard, roots: [""] },
|
||||
{
|
||||
labelKey: "peopleSupport",
|
||||
icon: Users,
|
||||
roots: [
|
||||
"users",
|
||||
"online",
|
||||
"tickets",
|
||||
"help-tickets",
|
||||
"bans",
|
||||
"ip",
|
||||
"vpn",
|
||||
"wordfilter",
|
||||
"moderation",
|
||||
"rooms",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "catalogFurniture",
|
||||
icon: Package,
|
||||
roots: [
|
||||
"studio",
|
||||
"catalog",
|
||||
"import",
|
||||
"items",
|
||||
"rare-values",
|
||||
"badges",
|
||||
"achievements",
|
||||
"sounds",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "communityContent",
|
||||
icon: Newspaper,
|
||||
roots: [
|
||||
"articles",
|
||||
"photos",
|
||||
"events",
|
||||
"polls",
|
||||
"guilds",
|
||||
"media",
|
||||
"radio",
|
||||
"ads",
|
||||
"banners",
|
||||
"navigation",
|
||||
"help-questions",
|
||||
"writeable-boxes",
|
||||
"tags",
|
||||
"prefixes",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "economy",
|
||||
icon: ShoppingCart,
|
||||
roots: [
|
||||
"shop",
|
||||
"marketplace",
|
||||
"transactions",
|
||||
"vouchers",
|
||||
"subscriptions",
|
||||
"calendar",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "staffPermissions",
|
||||
icon: UsersRound,
|
||||
roots: ["teams", "applications", "permissions"],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Cog,
|
||||
roots: [
|
||||
"settings",
|
||||
"theme",
|
||||
"theme-builder",
|
||||
"maintenance",
|
||||
"favicon",
|
||||
"email-templates",
|
||||
"translations",
|
||||
"emulator",
|
||||
"housekeeping",
|
||||
"commandocentrum",
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "monitoring",
|
||||
icon: Activity,
|
||||
roots: ["logs", "analytics", "devops", "alerts"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Owner order takes precedence; hidden entries are filtered before regrouping. */
|
||||
export function visibleAdminNavigation(
|
||||
options: AdminNavigationOptions,
|
||||
): AdminNavGroup[] {
|
||||
const allowed = new Set(options.allowedPermissions);
|
||||
const groups = applyAdminNavConfig(ADMIN_NAV_GROUPS, options.navConfig)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) =>
|
||||
navItemIsAllowed(item, {
|
||||
isSuperAdmin: options.isSuperAdmin,
|
||||
has: (slug) => allowed.has(slug),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length);
|
||||
if (
|
||||
options.navConfig.groupOrder?.length ||
|
||||
Object.values(options.navConfig.itemOrder ?? {}).some(
|
||||
(order) => order.length,
|
||||
)
|
||||
)
|
||||
return groups;
|
||||
const remaining = new Map(
|
||||
groups.flatMap((group) => group.items).map((item) => [item.href, item]),
|
||||
);
|
||||
const organized = sections
|
||||
.map((section) => {
|
||||
const items = [...remaining.values()].filter((item) =>
|
||||
section.roots.includes(item.href.split("/")[2] ?? ""),
|
||||
);
|
||||
for (const item of items) remaining.delete(item.href);
|
||||
return { labelKey: section.labelKey, icon: section.icon, items };
|
||||
})
|
||||
.filter((group) => group.items.length);
|
||||
// New routes remain discoverable even before being assigned to an activity.
|
||||
if (remaining.size)
|
||||
organized.push({
|
||||
labelKey: "tools",
|
||||
icon: Cog,
|
||||
items: [...remaining.values()],
|
||||
});
|
||||
return organized;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: new Set<string>(),
|
||||
queried: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
|
||||
handler(request, {
|
||||
session: { user: { id: 1, rank: 7 } },
|
||||
permissions: { isSuperAdmin: false },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tables = Object.fromEntries(
|
||||
[
|
||||
"User",
|
||||
"Rooms",
|
||||
"Guilds",
|
||||
"WebsiteArticles",
|
||||
"WebsiteRareValues",
|
||||
"WebsiteShopArticles",
|
||||
].map((name) => [
|
||||
name,
|
||||
{
|
||||
name,
|
||||
id: `${name}Id`,
|
||||
username: `${name}Name`,
|
||||
mail: `${name}Mail`,
|
||||
slug: `${name}Slug`,
|
||||
ownerName: `${name}Owner`,
|
||||
itemId: `${name}Item`,
|
||||
description: `${name}Description`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...tables,
|
||||
db: {
|
||||
select() {
|
||||
let table: unknown;
|
||||
const query = {
|
||||
from(value: unknown) {
|
||||
table = value;
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
async limit() {
|
||||
state.queried.push(table);
|
||||
return [{ id: 1, title: "Match", subtitle: "Data" }];
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "@/app/api/admin/search/route";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
beforeEach(() => {
|
||||
state.allowed.clear();
|
||||
state.queried.length = 0;
|
||||
});
|
||||
const request = () =>
|
||||
({
|
||||
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
|
||||
}) as Parameters<typeof GET>[0];
|
||||
it("does not query or expose categories without their view permission", async () => {
|
||||
const response = await GET(request(), {});
|
||||
expect((await response.json()).results).toEqual([]);
|
||||
expect(state.queried).toHaveLength(0);
|
||||
});
|
||||
it("searches only the permitted category", async () => {
|
||||
state.allowed.add(PERMS.NEWS_VIEW);
|
||||
const response = await GET(request(), {});
|
||||
const body = await response.json();
|
||||
expect(body.results).toHaveLength(1);
|
||||
expect(body.results[0].type).toBe("articles");
|
||||
expect(state.queried).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { tablePageNumbers } from "./admin-table-state";
|
||||
|
||||
it.each([
|
||||
[1, 1],
|
||||
[1, 7],
|
||||
[6, 7],
|
||||
[7, 7],
|
||||
[50, 100],
|
||||
[100, 100],
|
||||
])("includes the current page %i/%i and stays in bounds", (page, last) => {
|
||||
const pages = tablePageNumbers(page, last);
|
||||
expect(pages).toContain(page);
|
||||
expect(pages.length).toBeLessThanOrEqual(5);
|
||||
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
|
||||
expect(new Set(pages).size).toBe(pages.length);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** A centered, bounded window including the current page, also at either end. */
|
||||
export function tablePageNumbers(page: number, last: number): number[] {
|
||||
const count = Math.min(5, Math.max(1, last));
|
||||
const start = Math.max(1, Math.min(page - 2, last - count + 1));
|
||||
return Array.from({ length: count }, (_, index) => start + index);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cache } from "react";
|
||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
parseAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const loadAdminNavigation = cache(async () => {
|
||||
const [{ session, permissions }, raw] = await Promise.all([
|
||||
getAdminContext(),
|
||||
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||
]);
|
||||
const hasDashboard = canAccess(
|
||||
permissions,
|
||||
PERMS.ADMIN_DASHBOARD,
|
||||
session.user.rank,
|
||||
);
|
||||
return {
|
||||
isSuperAdmin: permissions.isSuperAdmin,
|
||||
allowedPermissions: permissions.isSuperAdmin
|
||||
? []
|
||||
: collectNavPermissionSlugs().filter(
|
||||
(slug) =>
|
||||
canAccess(permissions, slug, session.user.rank) ||
|
||||
(hasDashboard &&
|
||||
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
|
||||
),
|
||||
navConfig: parseAdminNavConfig(raw),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { articleSlug, readArticleInput } from "./article-input";
|
||||
|
||||
it("normalizes Next catch-all slug parameters", () => {
|
||||
expect(articleSlug(["welcome"])).toBe("welcome");
|
||||
expect(articleSlug("welcome")).toBe("welcome");
|
||||
});
|
||||
it("keeps the selected timezone instant when scheduling", () => {
|
||||
const form = new FormData();
|
||||
form.set("title", "News");
|
||||
form.set("status", "scheduled");
|
||||
form.set("publishAt", "2026-09-06T20:00:00+02:00");
|
||||
expect(readArticleInput(form).publishAt?.toISOString()).toBe(
|
||||
"2026-09-06T18:00:00.000Z",
|
||||
);
|
||||
});
|
||||
it("ships additive publication columns for existing installations", () => {
|
||||
const sql = readFileSync(
|
||||
"drizzle/migrations/0025_article_publication.sql",
|
||||
"utf8",
|
||||
);
|
||||
for (const column of ["status", "publish_at", "published_at"])
|
||||
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS ${column}`);
|
||||
expect(sql).toContain("DEFAULT 'published'");
|
||||
expect(sql).not.toMatch(/DROP|UPDATE\s+website_articles/i);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ActionResult } from "./safe-action-shared";
|
||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "titleRequired"
|
||||
| "titleTooLong"
|
||||
| "summaryTooLong"
|
||||
| "imageTooLong"
|
||||
| "invalidStatus"
|
||||
| "publishDateRequired",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export function readArticleInput(form: FormData) {
|
||||
const text = (key: string) =>
|
||||
String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const title = text("title"),
|
||||
shortStory = text("shortStory"),
|
||||
fullStory = text("fullStory"),
|
||||
image = text("image");
|
||||
if (!title) throw new ArticleInputError("titleRequired");
|
||||
if (title.length > 255) throw new ArticleInputError("titleTooLong");
|
||||
if (shortStory.length > 255) throw new ArticleInputError("summaryTooLong");
|
||||
if (image.length > 255) throw new ArticleInputError("imageTooLong");
|
||||
const status = text("status") || "published";
|
||||
if (status !== "published" && status !== "draft" && status !== "scheduled")
|
||||
throw new ArticleInputError("invalidStatus");
|
||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||
throw new ArticleInputError("publishDateRequired");
|
||||
return {
|
||||
title,
|
||||
shortStory,
|
||||
fullStory,
|
||||
image,
|
||||
status,
|
||||
publishAt,
|
||||
rawSlug: text("slug"),
|
||||
};
|
||||
}
|
||||
export function articleSlug(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join("/") : value;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type MessageFormatElement,
|
||||
parse,
|
||||
TYPE,
|
||||
} from "@formatjs/icu-messageformat-parser";
|
||||
|
||||
/** Argument names and rich-text tags are a contract with the calling component. */
|
||||
export function messageVariables(value: string): string[] {
|
||||
const names = new Set<string>();
|
||||
function visit(nodes: MessageFormatElement[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== TYPE.literal && node.type !== TYPE.pound)
|
||||
names.add(node.value);
|
||||
if (node.type === TYPE.select || node.type === TYPE.plural) {
|
||||
for (const option of Object.values(node.options)) visit(option.value);
|
||||
}
|
||||
if (node.type === TYPE.tag) visit(node.children);
|
||||
}
|
||||
}
|
||||
visit(parse(value));
|
||||
return [...names].sort();
|
||||
}
|
||||
export function validateCmsMessage(
|
||||
value: string,
|
||||
source: string,
|
||||
): "empty" | "invalid" | "variables" | null {
|
||||
if (!value.trim()) return "empty";
|
||||
try {
|
||||
return messageVariables(value).join("\0") ===
|
||||
messageVariables(source).join("\0")
|
||||
? null
|
||||
: "variables";
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readCmsTranslation, saveCmsTranslation } from "./cms-translations";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
async function root() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-translations-test-"));
|
||||
directories.push(dir);
|
||||
return dir;
|
||||
}
|
||||
describe("CMS translation persistence", () => {
|
||||
it("reads bundled messages without source files and persists only overrides", async () => {
|
||||
const dir = await root();
|
||||
const before = await readCmsTranslation("it", dir);
|
||||
expect(before.messages["pages.admin.search.users"]).toBe("Utenti");
|
||||
await saveCmsTranslation(
|
||||
"it",
|
||||
before.revision,
|
||||
{ "pages.admin.search.users": "Persone" },
|
||||
dir,
|
||||
);
|
||||
expect(
|
||||
(await readCmsTranslation("it", dir)).messages[
|
||||
"pages.admin.search.users"
|
||||
],
|
||||
).toBe("Persone");
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(dir, "it.json"), "utf8")),
|
||||
).toEqual({ "pages.admin.search.users": "Persone" });
|
||||
});
|
||||
it("rejects stale saves and invalid ICU without overwriting saved text", async () => {
|
||||
const dir = await root();
|
||||
const before = await readCmsTranslation("it", dir);
|
||||
await saveCmsTranslation(
|
||||
"it",
|
||||
before.revision,
|
||||
{ "pages.admin.search.users": "Persone" },
|
||||
dir,
|
||||
);
|
||||
await expect(
|
||||
saveCmsTranslation(
|
||||
"it",
|
||||
before.revision,
|
||||
{ "pages.admin.search.users": "Stale" },
|
||||
dir,
|
||||
),
|
||||
).rejects.toThrow("conflict");
|
||||
const current = await readCmsTranslation("it", dir);
|
||||
await expect(
|
||||
saveCmsTranslation(
|
||||
"it",
|
||||
current.revision,
|
||||
{ "pages.admin.dashboard.totalAccounts": "{broken" },
|
||||
dir,
|
||||
),
|
||||
).rejects.toThrow("invalid");
|
||||
await expect(
|
||||
saveCmsTranslation(
|
||||
"it",
|
||||
current.revision,
|
||||
{ "pages.admin.dashboard.totalAccounts": "No variable" },
|
||||
dir,
|
||||
),
|
||||
).rejects.toThrow("variables");
|
||||
expect((await readCmsTranslation("it", dir)).revision).toBe(
|
||||
current.revision,
|
||||
);
|
||||
});
|
||||
it("restores the bundled value when an override is reset", async () => {
|
||||
const dir = await root();
|
||||
const before = await readCmsTranslation("it", dir);
|
||||
const saved = await saveCmsTranslation(
|
||||
"it",
|
||||
before.revision,
|
||||
{ "pages.admin.search.users": "Persone" },
|
||||
dir,
|
||||
);
|
||||
const reset = await saveCmsTranslation(
|
||||
"it",
|
||||
saved.revision,
|
||||
{ "pages.admin.search.users": null },
|
||||
dir,
|
||||
);
|
||||
expect(reset.messages["pages.admin.search.users"]).toBe("Utenti");
|
||||
});
|
||||
it("rejects unknown message keys and does not mutate prototypes", async () => {
|
||||
const dir = await root();
|
||||
const before = await readCmsTranslation("it", dir);
|
||||
await expect(
|
||||
saveCmsTranslation(
|
||||
"it",
|
||||
before.revision,
|
||||
{ "__proto__.polluted": "yes" },
|
||||
dir,
|
||||
),
|
||||
).rejects.toThrow("unknownKey");
|
||||
expect(Object.prototype).not.toHaveProperty("polluted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { cache } from "react";
|
||||
import { type AppLocale, isSupportedLocale } from "@/i18n/locales";
|
||||
import type { MessageTree } from "@/i18n/merge-messages";
|
||||
import { validateCmsMessage } from "./cms-message-validation";
|
||||
import { logger } from "./logger";
|
||||
import {
|
||||
withGamedataLock,
|
||||
writeGamedataJsonAtomic,
|
||||
} from "./services/import/core/gamedata-json";
|
||||
import { flattenObject, unflattenObject } from "./translations-utils";
|
||||
|
||||
const defaultRoot = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
"storage",
|
||||
"cms-translations",
|
||||
);
|
||||
export interface CmsTranslationSnapshot {
|
||||
messages: Record<string, string>;
|
||||
revision: string;
|
||||
}
|
||||
export class CmsTranslationError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "conflict"
|
||||
| "unknownKey"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "variables",
|
||||
public readonly key = "",
|
||||
) {
|
||||
super(`${code}: ${key}`);
|
||||
}
|
||||
}
|
||||
async function bundled(locale: AppLocale): Promise<Record<string, string>> {
|
||||
return flattenObject((await import(`../messages/${locale}.json`)).default);
|
||||
}
|
||||
async function readOverrides(file: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"),
|
||||
);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.values(parsed).some((v) => typeof v !== "string")
|
||||
)
|
||||
throw new Error("Invalid CMS translation override file");
|
||||
return parsed as Record<string, string>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function localeFile(locale: AppLocale, root: string) {
|
||||
if (!isSupportedLocale(locale)) throw new Error("Unsupported locale");
|
||||
return path.join(root, `${locale}.json`);
|
||||
}
|
||||
export async function readCmsTranslation(
|
||||
locale: AppLocale,
|
||||
root = defaultRoot,
|
||||
): Promise<CmsTranslationSnapshot> {
|
||||
const [base, source, overrides] = await Promise.all([
|
||||
bundled(locale),
|
||||
bundled("en"),
|
||||
readOverrides(localeFile(locale, root)),
|
||||
]);
|
||||
const messages = { ...base };
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
// Obsolete or incompatible overrides must not break new releases.
|
||||
if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key]))
|
||||
continue;
|
||||
messages[key] = value;
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
revision: createHash("sha256")
|
||||
.update(JSON.stringify([base, overrides]))
|
||||
.digest("hex"),
|
||||
};
|
||||
}
|
||||
export async function saveCmsTranslation(
|
||||
locale: AppLocale,
|
||||
revision: string,
|
||||
changes: Record<string, string | null>,
|
||||
root = defaultRoot,
|
||||
): Promise<CmsTranslationSnapshot> {
|
||||
const file = localeFile(locale, root);
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
return withGamedataLock(file, async () => {
|
||||
const current = await readCmsTranslation(locale, root);
|
||||
if (current.revision !== revision)
|
||||
throw new CmsTranslationError("conflict");
|
||||
const [base, source, overrides] = await Promise.all([
|
||||
bundled(locale),
|
||||
bundled("en"),
|
||||
readOverrides(file),
|
||||
]);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (!Object.hasOwn(source, key))
|
||||
throw new CmsTranslationError("unknownKey", key);
|
||||
if (value === null || value === base[key]) {
|
||||
delete overrides[key];
|
||||
continue;
|
||||
}
|
||||
const issue = validateCmsMessage(value, source[key]);
|
||||
if (issue) throw new CmsTranslationError(issue, key);
|
||||
overrides[key] = value;
|
||||
}
|
||||
await writeGamedataJsonAtomic(file, overrides);
|
||||
return readCmsTranslation(locale, root);
|
||||
});
|
||||
}
|
||||
/** Per-request deduplication; runtime edits are visible on the next request. */
|
||||
export const loadCmsMessages = cache(
|
||||
async (locale: AppLocale): Promise<MessageTree> => {
|
||||
try {
|
||||
return unflattenObject(
|
||||
(await readCmsTranslation(locale)).messages,
|
||||
) as MessageTree;
|
||||
} catch (error) {
|
||||
logger.error("CMS translation overrides could not be loaded", {
|
||||
locale,
|
||||
error,
|
||||
});
|
||||
return (await import(`../messages/${locale}.json`)).default;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { ImportJob } from "./import-job";
|
||||
import { retryableJobItems } from "./import-job-retry";
|
||||
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
}) as ImportJob;
|
||||
it("only retries failures after a completed job", () =>
|
||||
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
]));
|
||||
it("recovers all unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ImportJob } from "./import-job";
|
||||
/** Never requeue an active job or furniture already imported successfully. */
|
||||
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||
if (job.state === "running" || job.state === "queued") return [];
|
||||
return job.items.filter(
|
||||
(item) =>
|
||||
item.state === "failed" ||
|
||||
(job.state === "interrupted" && item.state !== "done"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
revision: "0",
|
||||
values: new Map<string, unknown>(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
status: "ready",
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: state }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
|
||||
if (state.values.has(key)) return state.values.get(key);
|
||||
const result = await query();
|
||||
state.values.set(key, result);
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
import { cacheNews, invalidateNewsCache } from "./news-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
state.values.clear();
|
||||
state.revision = "0";
|
||||
state.status = "ready";
|
||||
state.get.mockReset().mockImplementation(async () => state.revision);
|
||||
state.set
|
||||
.mockReset()
|
||||
.mockImplementation(async (_key: string, value: string) => {
|
||||
state.revision = value;
|
||||
});
|
||||
});
|
||||
it("invalidates a previously cached public list", async () => {
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(["new article"]);
|
||||
expect(await cacheNews("list", 60000, query)).toEqual([]);
|
||||
await invalidateNewsCache();
|
||||
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
|
||||
});
|
||||
it("does not let a stale in-flight read replace a newer revision", async () => {
|
||||
let finish!: (value: string[]) => void;
|
||||
const old = cacheNews(
|
||||
"list",
|
||||
60000,
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
await invalidateNewsCache();
|
||||
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
|
||||
finish([]);
|
||||
await old;
|
||||
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
|
||||
"new",
|
||||
]);
|
||||
});
|
||||
it("reads fresh data when Redis is unavailable", async () => {
|
||||
state.get.mockRejectedValue(Error("offline"));
|
||||
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
|
||||
"fresh",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const REVISION_KEY = "cms:news:revision";
|
||||
export async function cacheNews<T>(
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
query: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!redis || redis.status === "end") return query();
|
||||
let revision: string;
|
||||
try {
|
||||
revision = (await redis.get(REVISION_KEY)) ?? "0";
|
||||
} catch {
|
||||
return query();
|
||||
}
|
||||
return cached(`news:${revision}:${key}`, ttlMs, query);
|
||||
}
|
||||
export async function invalidateNewsCache(): Promise<void> {
|
||||
if (!redis || redis.status === "end") return;
|
||||
try {
|
||||
await redis.set(REVISION_KEY, randomUUID());
|
||||
} catch (error) {
|
||||
logger.error("News saved but public cache invalidation failed", {
|
||||
module: "news",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
import "server-only";
|
||||
|
||||
import { 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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
friends: [] as { id: number; username: string; online: string }[],
|
||||
}));
|
||||
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
|
||||
vi.mock("./site-settings", () => ({
|
||||
siteSettings: {
|
||||
get: async (_key: string, fallback: string) => fallback,
|
||||
getMany: async () => ({}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
return {
|
||||
...schema,
|
||||
db: {
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
let rows: unknown[] = [];
|
||||
const chain = {
|
||||
from: (table: unknown) => {
|
||||
if (table === schema.MessengerFriendships)
|
||||
rows =
|
||||
"value" in fields
|
||||
? [{ value: state.friends.length }]
|
||||
: state.friends;
|
||||
return chain;
|
||||
},
|
||||
where: () => chain,
|
||||
innerJoin: () => chain,
|
||||
orderBy: () => chain,
|
||||
limit: () => chain,
|
||||
catch: () => Promise.resolve(rows),
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
|
||||
then: (resolve: (value: unknown[]) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadUserDashboard } from "./user-dashboard";
|
||||
|
||||
describe("dashboard friendships", () => {
|
||||
beforeEach(() => {
|
||||
state.friends = [];
|
||||
});
|
||||
it("counts each friend once when both directional rows are returned", async () => {
|
||||
const wait = { id: 2, username: "wait", online: "1" };
|
||||
const other = { id: 3, username: "YouKnowMe", online: "1" };
|
||||
state.friends = [wait, other, { ...wait }, { ...other }];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||
});
|
||||
it("preserves single-direction and offline friendships", async () => {
|
||||
state.friends = [
|
||||
{ id: 2, username: "wait", online: "1" },
|
||||
{ id: 3, username: "offline", online: "0" },
|
||||
];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual(state.friends);
|
||||
expect(data.friendCountRows).toEqual([{ value: 2 }]);
|
||||
});
|
||||
it("excludes self friendships", async () => {
|
||||
state.friends = [{ id: 1, username: "me", online: "1" }];
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual([]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||
});
|
||||
it("returns zero for an empty friends list", async () => {
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data.friends).toEqual([]);
|
||||
expect(data.friendCountRows).toEqual([{ value: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
|
||||
recentRooms,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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": "Вторичен",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Секундарни",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вторинний",
|
||||
|
||||
Reference in new issue
Block a user