feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -27,12 +27,16 @@ vi.mock("next/navigation", () => ({
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const database = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
const result = Promise.resolve(state.rows.shift() ?? []);
|
||||
return Object.assign(result, { for: () => result });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
@@ -42,9 +46,13 @@ vi.mock("@/lib/db", async () => ({
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
||||
fn(database),
|
||||
};
|
||||
return { ...(await import("@/db/schema")), db: database };
|
||||
});
|
||||
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
@@ -89,11 +97,25 @@ describe("news saves", () => {
|
||||
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 }]];
|
||||
const existing = {
|
||||
title: "Old",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
};
|
||||
state.rows = [[existing]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
form({
|
||||
id: "1",
|
||||
baseToken: articleEditToken(existing),
|
||||
publishAt: "2099-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
@@ -106,6 +128,27 @@ describe("news saves", () => {
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
|
||||
state.rows = [
|
||||
[
|
||||
{
|
||||
title: "Changed by another editor",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "published",
|
||||
publishAt: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(
|
||||
await updateArticle(form({ id: "1", baseToken: "outdated" })),
|
||||
).toEqual({ ok: false, error: "editConflict" });
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
|
||||
@@ -4,7 +4,9 @@ import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
@@ -23,11 +25,15 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||
async function uniqueSlug(
|
||||
title: string,
|
||||
excludeId?: bigint,
|
||||
connection: Pick<typeof db, "select"> = db,
|
||||
): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
for (let n = 2; ; n++) {
|
||||
const [existing] = await db
|
||||
const [existing] = await connection
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
@@ -105,39 +111,54 @@ export async function updateArticle(
|
||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let becamePublished = false;
|
||||
let slug: string;
|
||||
let slug = "";
|
||||
try {
|
||||
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);
|
||||
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({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
payload: JSON.stringify({
|
||||
title: existing.title,
|
||||
slug: existing.slug,
|
||||
image: existing.image,
|
||||
shortStory: existing.shortStory,
|
||||
fullStory: existing.fullStory,
|
||||
status: existing.status,
|
||||
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||
baseToken: "",
|
||||
}),
|
||||
});
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id, tx);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await tx
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
@@ -170,6 +191,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleDrafts)
|
||||
.where(eq(ArticleDrafts.articleKey, String(id)));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
version: 0,
|
||||
update: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const tx = {
|
||||
insert: (table: unknown) => ({
|
||||
values: (value: unknown) => {
|
||||
state.insert(table, value);
|
||||
return { onDuplicateKeyUpdate: async () => {} };
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ for: async () => [{ version: state.version }] }),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(table, value);
|
||||
return { where: async () => {} };
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...(await import("@/db/schema")),
|
||||
db: { transaction: async (fn: (value: typeof tx) => unknown) => fn(tx) },
|
||||
};
|
||||
});
|
||||
|
||||
import { ArticleDrafts } from "@/db/article-editor";
|
||||
import { autosaveArticle } from "./article-recovery";
|
||||
|
||||
function draft() {
|
||||
const form = new FormData();
|
||||
form.set("title", "Incomplete title");
|
||||
form.set("status", "published");
|
||||
form.set("fullStory", "Work in progress");
|
||||
return form;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.version = 0;
|
||||
state.permission.mockResolvedValue({ id: 17 });
|
||||
});
|
||||
it("stores incomplete editor content only in a private draft table even when publication is selected", async () => {
|
||||
expect(await autosaveArticle("new", 0, draft())).toEqual({
|
||||
ok: true,
|
||||
version: 1,
|
||||
});
|
||||
expect(state.permission).toHaveBeenCalledWith("news.edit");
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ userId: 17, articleKey: "new" }),
|
||||
);
|
||||
expect(state.update).toHaveBeenCalledOnce();
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ version: 1 }),
|
||||
);
|
||||
});
|
||||
it("does not replace a more recent draft from another tab", async () => {
|
||||
state.version = 4;
|
||||
expect(await autosaveArticle("new", 3, draft())).toEqual({ ok: false });
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not access storage when the editor permission is denied", async () => {
|
||||
state.permission.mockRejectedValue(new Error("denied"));
|
||||
await expect(autosaveArticle("new", 0, draft())).rejects.toThrow("denied");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects excessive article content before writing", async () => {
|
||||
const form = draft();
|
||||
form.set("fullStory", "x".repeat(500_001));
|
||||
await expect(autosaveArticle("new", 0, form)).rejects.toThrow("draftInvalid");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
type ArticleDraft,
|
||||
articleKey,
|
||||
readArticleDraft,
|
||||
} from "@/lib/article-draft";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function loadArticleRecovery(key: string) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const revisions =
|
||||
key === "new"
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, BigInt(key)))
|
||||
.orderBy(desc(ArticleRevisions.id))
|
||||
.limit(20);
|
||||
return {
|
||||
version: draft?.version ?? 0,
|
||||
draft:
|
||||
draft && draft.payload !== "{}"
|
||||
? {
|
||||
version: draft.version,
|
||||
payload: JSON.parse(draft.payload) as ArticleDraft,
|
||||
}
|
||||
: null,
|
||||
revisions: revisions.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
payload: JSON.parse(r.payload) as ArticleDraft,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export async function autosaveArticle(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
form: FormData,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const payload = JSON.stringify(readArticleDraft(form));
|
||||
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0)
|
||||
throw new Error("draftInvalid");
|
||||
return db.transaction(async (tx) => {
|
||||
if (key !== "new") {
|
||||
const [article] = await tx
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, BigInt(key)))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!article) return { ok: false as const };
|
||||
}
|
||||
await tx
|
||||
.insert(ArticleDrafts)
|
||||
.values({ userId: staff.id, articleKey: key, version: 0, payload: "{}" })
|
||||
.onDuplicateKeyUpdate({ set: { articleKey: key } });
|
||||
const where = and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
);
|
||||
const [draft] = await tx
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(where)
|
||||
.for("update");
|
||||
if (!draft || draft.version !== expectedVersion)
|
||||
return { ok: false as const };
|
||||
await tx
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload, version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(where);
|
||||
return { ok: true as const, version: expectedVersion + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearArticleRecovery(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
await db
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload: "{}", version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
eq(ArticleDrafts.version, expectedVersion),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -247,3 +247,81 @@
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.shortcutHint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.shortcut {
|
||||
min-height: 64px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.shortcut:focus-visible,
|
||||
.friend:focus-visible,
|
||||
.room:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.eventDetails {
|
||||
min-width: 0;
|
||||
flex: 1 1 220px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.eventDetails h2 {
|
||||
margin: 8px 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.eventDetails p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.eventPhase {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
color: var(--color-text-readable);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 14%,
|
||||
var(--color-surface)
|
||||
);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.hero {
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 100px;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
}
|
||||
.identity h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.wallet {
|
||||
gap: 8px;
|
||||
}
|
||||
.currency {
|
||||
padding: 10px;
|
||||
}
|
||||
.event > a {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import styles from "./dashboard.module.css";
|
||||
@@ -75,10 +75,11 @@ export default async function MePage({
|
||||
referralsRows,
|
||||
friends,
|
||||
currencyRows,
|
||||
dashboardEvent,
|
||||
} = data;
|
||||
const userSettings = userSettingsRows[0];
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows?.[0]?.value ?? 0);
|
||||
const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
@@ -173,12 +174,20 @@ export default async function MePage({
|
||||
{ href: "/shop", label: t("shop"), count: 0 },
|
||||
].map((link) => (
|
||||
<Link key={link.href} href={link.href} className={styles.shortcut}>
|
||||
{link.label}
|
||||
<span>
|
||||
{link.label}
|
||||
{link.href === "/messages" && (
|
||||
<span className={styles.shortcutHint}>
|
||||
{unreadCountRows === null
|
||||
? t("messagesUnavailable")
|
||||
: unreadCount > 0
|
||||
? t("unread", { count: unreadCount })
|
||||
: t("messagesCaughtUp")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{link.count > 0 && (
|
||||
<span
|
||||
className={styles.count}
|
||||
title={t("unread", { count: link.count })}
|
||||
>
|
||||
<span className={styles.count} aria-hidden="true">
|
||||
{link.count > 99 ? "99+" : link.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -199,6 +208,48 @@ export default async function MePage({
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</section>
|
||||
<SurfaceCard
|
||||
title={t("eventTitle")}
|
||||
actionHref="/events"
|
||||
actionLabel={t("allEvents")}
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
{dashboardEvent.unavailable ? (
|
||||
<p className="muted" role="status">
|
||||
{t("eventsUnavailable")}
|
||||
</p>
|
||||
) : dashboardEvent.event ? (
|
||||
<div className={styles.event}>
|
||||
<div className={styles.eventDetails}>
|
||||
<span className={styles.eventPhase}>
|
||||
{t(
|
||||
dashboardEvent.event.phase === "ongoing"
|
||||
? "eventOngoing"
|
||||
: "eventUpcoming",
|
||||
)}
|
||||
</span>
|
||||
<h2>{dashboardEvent.event.title}</h2>
|
||||
<p className="muted">
|
||||
{dashboardEvent.event.typeName} ·{" "}
|
||||
<time dateTime={dashboardEvent.event.startsAt.toISOString()}>
|
||||
{format.dateTime(dashboardEvent.event.startsAt, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/events/${slugify(dashboardEvent.event.title)}-${dashboardEvent.event.id}`}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("eventDetails")} →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">{t("noUpcomingEvents")}</p>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
<div className={styles.columns}>
|
||||
<div className={styles.stack}>
|
||||
<SurfaceCard
|
||||
@@ -236,7 +287,11 @@ export default async function MePage({
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
<p className="muted">{t("roomsHint")}</p>
|
||||
{recentRooms.length === 0 ? (
|
||||
{recentRooms === null ? (
|
||||
<p className="muted" role="status">
|
||||
{t("roomsUnavailable")}
|
||||
</p>
|
||||
) : recentRooms.length === 0 ? (
|
||||
<p className="muted">{t("noRooms")}</p>
|
||||
) : (
|
||||
<div className={styles.rooms}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -62,6 +63,8 @@ export default async function EditArticle({
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
articleKey={String(article.id)}
|
||||
baseToken={articleEditToken(article)}
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function ArticleListSubmit() {
|
||||
const { pending } = useFormStatus();
|
||||
const t = useTranslations("pages.admin.articles");
|
||||
return (
|
||||
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||
{pending ? t("listLoading") : t("listApply")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+141
-77
@@ -1,107 +1,171 @@
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Form from "next/form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { loadArticleList } from "@/lib/services/article-list";
|
||||
import { ArticleListSubmit } from "./list-submit";
|
||||
|
||||
export default async function AdminArticles({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { error } = await searchParams;
|
||||
const params = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const articles = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
slug: WebsiteArticles.slug,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
userId: WebsiteArticles.userId,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(50)
|
||||
.catch(() => []);
|
||||
|
||||
const authorMap = new Map<number, string>();
|
||||
const authorIds = articles
|
||||
.map((a) => a.userId)
|
||||
.filter((id): id is number => Boolean(id));
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, authorIds))
|
||||
.catch(() => []);
|
||||
for (const u of authors) authorMap.set(u.id, u.username);
|
||||
let result: Awaited<ReturnType<typeof loadArticleList>> | null = null;
|
||||
try {
|
||||
result = await loadArticleList({
|
||||
status: params.status,
|
||||
search: params.search,
|
||||
page: Number(params.page ?? 1),
|
||||
});
|
||||
} catch {
|
||||
/* Render a retry state instead of an empty article list. */
|
||||
}
|
||||
|
||||
const latest = articles[0]?.createdAt
|
||||
? formatDate(articles[0].createdAt, "date")
|
||||
: "—";
|
||||
|
||||
const status =
|
||||
result?.status ??
|
||||
(["draft", "published", "scheduled"].includes(params.status ?? "")
|
||||
? params.status
|
||||
: "all");
|
||||
const search = result?.search ?? (params.search ?? "").slice(0, 191);
|
||||
const href = (page: number) =>
|
||||
`/admin/articles?${new URLSearchParams({ status: status ?? "all", search, page: String(page) })}`;
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={Newspaper}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
actions={
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? (
|
||||
<Button asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||
{params.error ? (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
|
||||
{t("listActionError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-2">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||
{articles.map((a) => (
|
||||
<ArticleCard
|
||||
key={String(a.id)}
|
||||
id={String(a.id)}
|
||||
title={a.title}
|
||||
image={a.image}
|
||||
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
|
||||
author={authorMap.get(a.userId ?? -1)}
|
||||
/>
|
||||
<Form
|
||||
action="/admin/articles"
|
||||
className="admin-card mb-5 flex flex-wrap items-end gap-3"
|
||||
key={`${status}:${search}`}
|
||||
>
|
||||
<label htmlFor="article-status" className="grid gap-1 text-sm">
|
||||
<span>{t("listStatus")}</span>
|
||||
<select
|
||||
id="article-status"
|
||||
name="status"
|
||||
defaultValue={status}
|
||||
className="h-9 rounded-md border bg-[var(--admin-surface)] px-3"
|
||||
>
|
||||
{["all", "draft", "published", "scheduled"].map((value) => (
|
||||
<option value={value} key={value}>
|
||||
{t(`listStatus_${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="article-search"
|
||||
className="grid flex-1 min-w-0 gap-1 text-sm"
|
||||
>
|
||||
<span>{t("listSearch")}</span>
|
||||
<Input
|
||||
id="article-search"
|
||||
name="search"
|
||||
defaultValue={search}
|
||||
maxLength={191}
|
||||
/>
|
||||
</label>
|
||||
<ArticleListSubmit />
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/articles">{t("listReset")}</Link>
|
||||
</Button>
|
||||
</Form>
|
||||
{!result ? (
|
||||
<div className="admin-card space-y-3" role="alert">
|
||||
<p>{t("listUnavailable")}</p>
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={href(Number(params.page) || 1)}
|
||||
>
|
||||
{t("listRetry")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{t("listTotal", { count: result.total })}
|
||||
</p>
|
||||
{result.rows.length === 0 ? (
|
||||
<div className="admin-empty">
|
||||
{search || status !== "all"
|
||||
? t("listNoMatches")
|
||||
: t("noArticles")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,260px),1fr))] gap-4">
|
||||
{result.rows.map((article) => (
|
||||
<div key={String(article.id)} className="min-w-0 space-y-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{["draft", "published", "scheduled"].includes(
|
||||
article.status,
|
||||
)
|
||||
? t(`listStatus_${article.status}`)
|
||||
: article.status}
|
||||
</span>
|
||||
<ArticleCard
|
||||
id={String(article.id)}
|
||||
title={article.title}
|
||||
image={article.image}
|
||||
createdAt={article.createdAt?.toISOString() ?? null}
|
||||
author={article.author ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
aria-label={t("listPagination")}
|
||||
className="mt-5 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
{result.page > 1 ? (
|
||||
<Link className="btn btn-outline" href={href(result.page - 1)}>
|
||||
{t("listPrevious")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t("listPage", { page: result.page, total: result.lastPage })}
|
||||
</span>
|
||||
{result.page < result.lastPage ? (
|
||||
<Link className="btn btn-outline" href={href(result.page + 1)}>
|
||||
{t("listNext")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { OperationsInbox } from "@/components/admin/operations-inbox";
|
||||
import Link from "@/components/link";
|
||||
import { env } from "@/env";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
@@ -126,6 +127,7 @@ export default async function CommandoCentrum() {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<OperationsInbox />
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
assign: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
|
||||
vi.mock("@/lib/error-monitor", () => ({
|
||||
ErrorStore: class {
|
||||
resolve = mocks.resolve;
|
||||
assign = mocks.assign;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
@@ -17,7 +19,7 @@ vi.mock("@/lib/permissions", () => ({
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { resolveCmsError } from "./actions";
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
it("requires edit permission before resolving or auditing", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
@@ -37,3 +39,33 @@ it("records who resolved the problem", async () => {
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("requires edit permission before assigning", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
await expect(assignCmsError(new FormData())).rejects.toThrow("denied");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
it("assigns only the authenticated staff member, ignoring submitted user IDs", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("fingerprint", "aabbccddeeff0011");
|
||||
data.set("assignment", "self");
|
||||
data.set("userId", "999");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
|
||||
expect(mocks.assign).toHaveBeenCalledWith("aabbccddeeff0011", 42);
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.assign" }),
|
||||
);
|
||||
data.set("assignment", "clear");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.assign).toHaveBeenLastCalledWith("aabbccddeeff0011", null);
|
||||
});
|
||||
it("rejects unsupported assignment commands", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("assignment", "other");
|
||||
await expect(assignCmsError(data)).rejects.toThrow("Invalid assignment");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,3 +15,20 @@ export async function resolveCmsError(data: FormData) {
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
|
||||
export async function assignCmsError(data: FormData) {
|
||||
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
|
||||
const mode = data.get("assignment");
|
||||
if (mode !== "self" && mode !== "clear")
|
||||
throw new Error("Invalid assignment operation");
|
||||
const fingerprint = String(data.get("fingerprint") ?? "");
|
||||
const userId = mode === "self" ? staff.id : null;
|
||||
await new ErrorStore().assign(fingerprint, userId);
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "cms.error.assign",
|
||||
target: "cms-error",
|
||||
after: { fingerprint, assignedUserId: userId },
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { resolveCmsError } from "./actions";
|
||||
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
export default async function CmsErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
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"]>>;
|
||||
|
||||
try {
|
||||
result = await new ErrorStore().read();
|
||||
} catch {
|
||||
@@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
|
||||
</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>();
|
||||
for (const r of filtered) {
|
||||
const list = groups.get(r.fingerprint) ?? [];
|
||||
list.push(r);
|
||||
groups.set(r.fingerprint, list);
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
|
||||
const page = Math.max(
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
) : null}
|
||||
{[...groups.entries()]
|
||||
|
||||
.slice((page - 1) * 25, page * 25)
|
||||
|
||||
.map(([fingerprint, events]) => {
|
||||
const r = events[0];
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
return (
|
||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||
<summary className="cursor-pointer">
|
||||
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({
|
||||
<p className="text-sm break-words">{r.message}</p>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("firstSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.firstAt ?? ""}>
|
||||
{metrics.firstAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("lastSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.lastAt ?? ""}>
|
||||
{metrics.lastAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("affectedUsers")}
|
||||
</dt>
|
||||
<dd>{metrics.affectedUsers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("unidentifiedEvents")}
|
||||
</dt>
|
||||
<dd>{metrics.unidentified}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("metricsScope")}
|
||||
</p>
|
||||
<p>
|
||||
{r.assignment
|
||||
? t("assignedStaff", { id: r.assignment.userId })
|
||||
: t("unassigned")}
|
||||
</p>
|
||||
{canAccess(
|
||||
permissions,
|
||||
PERMS.DEVOPS_EDIT,
|
||||
session.user.rank,
|
||||
) && (
|
||||
<form
|
||||
action={assignCmsError}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="fingerprint"
|
||||
value={fingerprint}
|
||||
/>
|
||||
{r.assignment?.userId !== session.user.id && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="self"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("assignSelf")}
|
||||
</button>
|
||||
)}
|
||||
{r.assignment && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="clear"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("clearAssignment")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{metrics.operationLink && (
|
||||
<Link
|
||||
href={metrics.operationLink}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("openOperation")}
|
||||
</Link>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
{t("releaseOccurrences")}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{metrics.releases.map((release) => (
|
||||
<li key={release.release} className="break-all">
|
||||
<code>{release.release}</code> ·{" "}
|
||||
{t("occurrences", { count: release.count })} ·{" "}
|
||||
{release.firstAt} — {release.lastAt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("releaseScope")}
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
{t("reference")}: <code className="break-all">{r.id}</code>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), access: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 1 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: mocks.access,
|
||||
PERMS: { DEVOPS_VIEW: "devops.view" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/admin/installation", () => ({
|
||||
inspectInstallation: mocks.inspect,
|
||||
}));
|
||||
vi.mock("../../commandocentrum/refresh-status", () => ({
|
||||
RefreshStatus: () => null,
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
it("rejects unauthorized access before checking database, filesystem or worker", async () => {
|
||||
mocks.access.mockReturnValue(false);
|
||||
await expect(Page()).rejects.toThrow("redirect");
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { inspectInstallation } from "@/lib/admin/installation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { RefreshStatus } from "../../commandocentrum/refresh-status";
|
||||
export default async function InstallationPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.installation");
|
||||
const result = await inspectInstallation();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p>{t("hint")}</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t("checked", {
|
||||
time: formatDate(new Date(result.checkedAt), "datetime-seconds"),
|
||||
ms: result.durationMs,
|
||||
})}
|
||||
</p>
|
||||
<RefreshStatus label={t("recheck")} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{result.rows.map((row) => (
|
||||
<section key={row.key} className="admin-card space-y-2 min-w-0">
|
||||
<h2 className="font-semibold">{t(row.key)}</h2>
|
||||
<p
|
||||
className={
|
||||
row.state === "failed"
|
||||
? "text-destructive"
|
||||
: row.state === "ok"
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-text-muted)]"
|
||||
}
|
||||
>
|
||||
{t(row.state)}
|
||||
</p>
|
||||
{row.detail ? (
|
||||
<code className="block break-all text-xs">{row.detail}</code>
|
||||
) : null}
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t(`${row.key}Hint`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,12 +53,16 @@ export default async function DevOpsPage() {
|
||||
|
||||
const t = await getTranslations("pages.admin.devops");
|
||||
const data = await getDevOpsData();
|
||||
const installation = await getTranslations("pages.admin.installation");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
{t("cmsErrors")}
|
||||
</a>
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{installation("title")}
|
||||
</Link>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const keys = ["actor", "action", "from", "to"] as const;
|
||||
export function AuditFilters() {
|
||||
const t = useTranslations("pages.admin.logs.audit");
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
function apply(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = new FormData(event.currentTarget);
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) {
|
||||
const value = String(values.get(key) ?? "").trim();
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
function clear() {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) next.delete(key);
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
return (
|
||||
<form onSubmit={apply} key={params.toString()} aria-busy={pending}>
|
||||
<fieldset disabled={pending} className="space-y-3 rounded-xl border p-4">
|
||||
<legend className="px-1 text-sm font-medium">
|
||||
{t("filtersTitle")}
|
||||
</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{keys.map((key) => (
|
||||
<label
|
||||
htmlFor={`audit-${key}`}
|
||||
key={key}
|
||||
className="space-y-1 text-sm"
|
||||
>
|
||||
<span>{t(`filter${key[0].toUpperCase()}${key.slice(1)}`)}</span>
|
||||
<Input
|
||||
id={`audit-${key}`}
|
||||
name={key}
|
||||
type={key === "from" || key === "to" ? "date" : "text"}
|
||||
maxLength={191}
|
||||
defaultValue={params.get(key) ?? ""}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("filtersHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
{t("applyFilters")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clear}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -14,75 +15,73 @@ interface AuditRow {
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
function DiffCell({ row }: { row: AuditRow }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff)
|
||||
const { changes, invalid } = readAuditChanges(
|
||||
row.diff,
|
||||
row.before,
|
||||
row.after,
|
||||
);
|
||||
if (invalid)
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("audit.invalidDetails")}
|
||||
</span>
|
||||
);
|
||||
if (!changes.length)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: t("audit.changesCount", { count: keys.length });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{summary}
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 text-xs">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">
|
||||
{String(parsed[k].from ?? "∅")}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{String(parsed[k].to ?? "∅")}
|
||||
</span>
|
||||
<details className="max-w-xl text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("audit.changesCount", { count: changes.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{changes.map((change) => (
|
||||
<div key={change.key} className="space-y-1">
|
||||
<p className="font-medium break-all">{change.key}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.beforeValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.from)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.afterValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.to)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
const params = useSearchParams();
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{ key: "username", label: t("colUser"), sortable: true },
|
||||
{ key: "id", label: t("colId") },
|
||||
{ key: "username", label: t("colUser") },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
@@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: "target", label: t("colTarget"), sortable: true },
|
||||
{ key: "target", label: t("colTarget") },
|
||||
{
|
||||
key: "targetId",
|
||||
label: t("audit.colTargetId"),
|
||||
@@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: t("audit.colDate"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span>{String(value) || t("audit.notAvailable")}</span>
|
||||
),
|
||||
@@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<AuditFilters />
|
||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,15 @@ export default async function AuditLogsPage({
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: sp.get("actor") ?? undefined,
|
||||
action: sp.get("action") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
|
||||
@@ -32,6 +40,8 @@ export default async function AuditLogsPage({
|
||||
target: r.target,
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
before: r.before,
|
||||
after: r.after,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
options: {} as Record<string, unknown>,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 2,
|
||||
username: '=HYPERLINK("bad")',
|
||||
action: "update",
|
||||
target: "user",
|
||||
targetId: 3,
|
||||
createdAt: "2026-09-09",
|
||||
diff: '{"password":{"from":"oldsecret","to":"newsecret"}}',
|
||||
before: null,
|
||||
after: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: Record<string, unknown>, handler: unknown) => {
|
||||
state.options = options;
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { LOGS_VIEW: "logs.view" } }));
|
||||
vi.mock("@/lib/services/audit", () => ({
|
||||
getAuditLogs: vi.fn(async () => ({ rows: state.rows })),
|
||||
}));
|
||||
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("audit CSV", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuditLogs).mockClear();
|
||||
});
|
||||
it("requires the audit viewing permission and passes the same filters and page", async () => {
|
||||
expect(state.options).toMatchObject({ permission: "logs.view" });
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"https://example.test/api/admin/logs/audit/export?actor=Alice&action=update&from=2026-09-09&to=2026-09-10&search=user&page=2&perPage=10",
|
||||
) as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(getAuditLogs).toHaveBeenCalledWith({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-10",
|
||||
search: "user",
|
||||
page: 2,
|
||||
perPage: 10,
|
||||
});
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
||||
const response = await GET(
|
||||
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
||||
{} as never,
|
||||
);
|
||||
const csv = await response.text();
|
||||
expect(csv).toContain("'=HYPERLINK");
|
||||
expect(csv).not.toContain("oldsecret");
|
||||
expect(csv).not.toContain("newsecret");
|
||||
expect(csv).toContain("[Redacted]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
|
||||
function cell(value: unknown) {
|
||||
const raw = value == null ? "" : String(value);
|
||||
const safe = /^[\s]*[=+@-]/.test(raw) ? `'${raw}` : raw;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
}
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.LOGS_VIEW },
|
||||
async (request) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const { search, page, perPage } = parseListParams(params);
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: params.get("actor") ?? undefined,
|
||||
action: params.get("action") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
});
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
"id",
|
||||
"user_id",
|
||||
"username",
|
||||
"action",
|
||||
"target",
|
||||
"target_id",
|
||||
"created_at",
|
||||
"changes",
|
||||
],
|
||||
];
|
||||
for (const row of result.rows) {
|
||||
const details = readAuditChanges(row.diff, row.before, row.after);
|
||||
rows.push([
|
||||
row.id,
|
||||
row.userId,
|
||||
row.username,
|
||||
row.action,
|
||||
row.target,
|
||||
row.targetId,
|
||||
row.createdAt,
|
||||
details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes
|
||||
.map(
|
||||
(change) =>
|
||||
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
||||
)
|
||||
.join("\n"),
|
||||
]);
|
||||
}
|
||||
return new Response(
|
||||
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="audit-page.csv"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
source: vi.fn(),
|
||||
@@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
|
||||
vi.mock("@/lib/services/furni-job-worker", () => ({
|
||||
drainFurnitureImports: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-job-store", () => ({
|
||||
vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
...(await original<typeof import("@/lib/services/furni-job-store")>()),
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
import { GET, PATCH, POST } from "./route";
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
@@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => {
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
it("only returns the current operators history", async () => {
|
||||
mocks.list.mockResolvedValue([
|
||||
{ id: "a", userId: 7 },
|
||||
{ id: "b", userId: 9 },
|
||||
]);
|
||||
it("requests bounded owner-scoped history from the store", async () => {
|
||||
mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
|
||||
const response = await GET(request({}), ctx);
|
||||
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
|
||||
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
|
||||
});
|
||||
it("requests cancellation with the authenticated owner", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
|
||||
const response = await PATCH(request({ id }), ctx);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("does not reveal other owners' jobs", async () => {
|
||||
mocks.cancel.mockResolvedValue(null);
|
||||
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
|
||||
});
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
|
||||
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -38,10 +38,10 @@ export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
after(drainFurnitureImports);
|
||||
const jobs = (await new ImportJobStore().list())
|
||||
.filter((job) => job.userId === ctx.session.user.id)
|
||||
.reverse()
|
||||
.slice(0, 30);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 30,
|
||||
});
|
||||
return apiOk({ jobs });
|
||||
},
|
||||
);
|
||||
@@ -82,3 +82,18 @@ export const POST = withAdmin(
|
||||
return apiOk({ job });
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job) return apiError("Import job not found", 404);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
},
|
||||
);
|
||||
@@ -4,18 +4,24 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
action,
|
||||
defaultValues,
|
||||
articleKey = "new",
|
||||
baseToken = "",
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
|
||||
edit?: boolean;
|
||||
articleKey?: string;
|
||||
baseToken?: string;
|
||||
defaultValues?: {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
@@ -55,6 +61,31 @@ export function ArticleForm({
|
||||
}, [defaultValues?.publishAt]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [token, setToken] = useState(baseToken);
|
||||
const [summary, setSummary] = useState(defaultValues?.shortStory ?? "");
|
||||
const [body, setBody] = useState(defaultValues?.fullStory ?? "");
|
||||
const [bodyVersion, setBodyVersion] = useState(0);
|
||||
function restoreDraft(draft: ArticleDraft, revision: boolean) {
|
||||
setTitle(draft.title);
|
||||
setSlug(draft.slug);
|
||||
setSlugTouched(true);
|
||||
setImage(draft.image);
|
||||
setImageError(false);
|
||||
setSummary(draft.shortStory);
|
||||
setBody(draft.fullStory);
|
||||
setBodyVersion((v) => v + 1);
|
||||
setStatus("draft");
|
||||
setPublishAt("");
|
||||
setToken(revision ? baseToken : draft.baseToken);
|
||||
markDirty();
|
||||
}
|
||||
const recovery = useArticleRecovery(
|
||||
articleKey,
|
||||
formRef,
|
||||
saving,
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -76,12 +107,14 @@ export function ArticleForm({
|
||||
setSaveError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
if (result?.ok) await recovery.clear().catch(() => {});
|
||||
if (result?.ok && result.data?.redirectTo)
|
||||
router.push(result.data.redirectTo);
|
||||
} catch (error) {
|
||||
@@ -104,6 +137,8 @@ export function ArticleForm({
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
>
|
||||
<fieldset disabled={pending} className="contents">
|
||||
<input type="hidden" name="baseToken" value={token} />
|
||||
{recovery.panel}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm">
|
||||
{saveError}
|
||||
@@ -193,7 +228,8 @@ export function ArticleForm({
|
||||
<textarea
|
||||
name="shortStory"
|
||||
maxLength={255}
|
||||
defaultValue={defaultValues?.shortStory ?? ""}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
@@ -205,9 +241,10 @@ export function ArticleForm({
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
name="fullStory"
|
||||
defaultValue={defaultValues?.fullStory ?? ""}
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={12}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
autosaveArticle,
|
||||
clearArticleRecovery,
|
||||
loadArticleRecovery,
|
||||
} from "@/actions/article-recovery";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
|
||||
export function useArticleRecovery(
|
||||
key: string,
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
saving: RefObject<boolean>,
|
||||
dirty: boolean,
|
||||
restore: (draft: ArticleDraft, revision: boolean) => void,
|
||||
) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const [recovery, setRecovery] = useState<Awaited<
|
||||
ReturnType<typeof loadArticleRecovery>
|
||||
> | null>(null);
|
||||
const [message, setMessage] = useState("autosaveLoading");
|
||||
const [reload, setReload] = useState(0);
|
||||
const version = useRef(0);
|
||||
const request = useRef<Promise<void> | null>(null);
|
||||
const last = useRef("");
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
last.current = "";
|
||||
setMessage("autosaveLoading");
|
||||
loadArticleRecovery(key)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "recoveryFound" : "autosaveReady");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
});
|
||||
const timer = setInterval(() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!dirtyRef.current ||
|
||||
saving.current ||
|
||||
request.current ||
|
||||
!form.current
|
||||
)
|
||||
return;
|
||||
const data = new FormData(form.current);
|
||||
const snapshot = JSON.stringify(Array.from(data.entries()));
|
||||
if (snapshot === last.current) return;
|
||||
setMessage("autosaveSaving");
|
||||
request.current = autosaveArticle(key, version.current, data)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
if (!result.ok) {
|
||||
blocked.current = true;
|
||||
setMessage("autosaveConflict");
|
||||
return;
|
||||
}
|
||||
version.current = result.version;
|
||||
last.current = snapshot;
|
||||
setMessage("autosaveSaved");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
})
|
||||
.finally(() => {
|
||||
request.current = null;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
wait: () => request.current ?? Promise.resolve(),
|
||||
clear: () => clearArticleRecovery(key, version.current),
|
||||
panel: (
|
||||
<div className="grid gap-2 text-xs" aria-live="polite">
|
||||
<p>{t(message)}</p>
|
||||
{(message === "autosaveFailed" || message === "autosaveConflict") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (saving.current) return;
|
||||
// Reconcile server versions without replacing any current form contents.
|
||||
blocked.current = true;
|
||||
await request.current;
|
||||
setReload((current) => current + 1);
|
||||
}}
|
||||
>
|
||||
{t("retryRecovery")}
|
||||
</button>
|
||||
)}
|
||||
{recovery?.draft && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (!recovery.draft || !window.confirm(t("confirmRecovery")))
|
||||
return;
|
||||
restore(recovery.draft.payload, false);
|
||||
setRecovery({ ...recovery, draft: null });
|
||||
blocked.current = false;
|
||||
setMessage("autosaveReady");
|
||||
}}
|
||||
>
|
||||
{t("recoverDraft")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t("confirmDiscardDraft"))) return;
|
||||
try {
|
||||
await clearArticleRecovery(key, version.current);
|
||||
// Reload the version in case another tab changed it before discarding.
|
||||
const data = await loadArticleRecovery(key);
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "autosaveConflict" : "autosaveReady");
|
||||
} catch {
|
||||
setMessage("autosaveFailed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("discardDraft")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!recovery?.revisions.length && (
|
||||
<details>
|
||||
<summary>{t("revisionHistory")}</summary>
|
||||
<ul className="grid gap-2 mt-2">
|
||||
{recovery.revisions.map((revision) => (
|
||||
<li key={revision.id} className="flex gap-2 items-center">
|
||||
<time dateTime={revision.createdAt}>
|
||||
{new Date(revision.createdAt).toLocaleString()}
|
||||
</time>
|
||||
<span>{revision.payload.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!window.confirm(t("confirmRevision"))
|
||||
)
|
||||
return;
|
||||
restore(revision.payload, true);
|
||||
}}
|
||||
disabled={Boolean(recovery.draft)}
|
||||
>
|
||||
{t("restoreRevision")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -106,7 +106,42 @@ export function DataTable<T extends { id: number | string }>({
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const bulkLock = useRef(false);
|
||||
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [columnPreference, setColumnPreference] = useState<{
|
||||
path: string;
|
||||
hidden: Set<string>;
|
||||
}>(() => ({ path: pathname, hidden: new Set() }));
|
||||
const hiddenColumns =
|
||||
columnPreference.path === pathname &&
|
||||
!columns.every((column) => columnPreference.hidden.has(column.key))
|
||||
? columnPreference.hidden
|
||||
: new Set<string>();
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(
|
||||
sessionStorage.getItem(`cms-table-columns:${pathname}`) ?? "[]",
|
||||
);
|
||||
const hidden = Array.isArray(raw)
|
||||
? raw.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
setColumnPreference({ path: pathname, hidden: new Set(hidden) });
|
||||
} catch {
|
||||
setColumnPreference({ path: pathname, hidden: new Set() });
|
||||
}
|
||||
}, [pathname]);
|
||||
function setHiddenColumns(update: (previous: Set<string>) => Set<string>) {
|
||||
const next = update(hiddenColumns);
|
||||
// Leave at least one data column available, even after a stale saved preference.
|
||||
if (columns.every((column) => next.has(column.key))) return;
|
||||
setColumnPreference({ path: pathname, hidden: next });
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`cms-table-columns:${pathname}`,
|
||||
JSON.stringify([...next]),
|
||||
);
|
||||
} catch {
|
||||
/* Storage can be disabled by the browser. */
|
||||
}
|
||||
}
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { count, eq, ne } from "drizzle-orm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { collectOperations } from "@/lib/admin/operations-inbox";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
|
||||
export async function OperationsInbox() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const allowed = (permission: string) =>
|
||||
canAccess(permissions, permission, session.user.rank);
|
||||
const t = await getTranslations("pages.admin.operations");
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: allowed(PERMS.DEVOPS_VIEW),
|
||||
load: async () => {
|
||||
const { records } = await new ErrorStore().read();
|
||||
const total = new Set(
|
||||
records
|
||||
.filter((record) => !record.resolved)
|
||||
.map((record) => record.fingerprint),
|
||||
).size;
|
||||
return [
|
||||
{
|
||||
id: "unresolved",
|
||||
source: "errors",
|
||||
count: total,
|
||||
severity: 3,
|
||||
href: "/admin/devops/cms-errors?status=open",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "imports",
|
||||
allowed: allowed(PERMS.ASSETS_IMPORT),
|
||||
load: async () => {
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: Number(session.user.id),
|
||||
limit: 30,
|
||||
});
|
||||
const total = jobs.filter(
|
||||
(job) =>
|
||||
job.state === "interrupted" ||
|
||||
job.items.some((item) => item.state === "failed"),
|
||||
).length;
|
||||
return [
|
||||
{
|
||||
id: "attention",
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "tickets",
|
||||
allowed: allowed(PERMS.TICKETS_VIEW),
|
||||
load: async () => {
|
||||
const [rows, helpRows] = 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 [
|
||||
{
|
||||
id: "open",
|
||||
source: "tickets",
|
||||
count:
|
||||
Number(rows[0]?.total ?? 0) + Number(helpRows[0]?.total ?? 0),
|
||||
severity: 1,
|
||||
href: "/admin/tickets",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "publications",
|
||||
allowed: allowed(PERMS.NEWS_VIEW),
|
||||
load: async () => {
|
||||
const rows = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.status, "draft"));
|
||||
return [
|
||||
{
|
||||
id: "drafts",
|
||||
source: "publications",
|
||||
count: Number(rows[0]?.total ?? 0),
|
||||
severity: 0,
|
||||
href: "/admin/articles?status=draft",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<section
|
||||
className="admin-card mb-6 space-y-3"
|
||||
aria-labelledby="operations-title"
|
||||
>
|
||||
<h2 id="operations-title" className="text-lg font-semibold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
{result.items.length === 0 && result.unavailable.length === 0 ? (
|
||||
<p>{t("empty")}</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{result.items.map((item) => (
|
||||
<Link
|
||||
key={`${item.source}:${item.id}`}
|
||||
href={item.href}
|
||||
className="rounded-lg border border-[var(--admin-border)] p-3 focus-visible:outline-2"
|
||||
>
|
||||
<span>{t(item.source)}</span>
|
||||
<strong className="float-right">{item.count}</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{result.unavailable.map((source) => (
|
||||
<p
|
||||
key={source}
|
||||
role="status"
|
||||
className="text-sm text-[var(--admin-warning)]"
|
||||
>
|
||||
{t("unavailable", { source: t(source) })}
|
||||
</p>
|
||||
))}
|
||||
{allowed(PERMS.DEVOPS_VIEW) ? (
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{t("installation")}
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureCompleteness } from "./furniture-completeness";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
const empty: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [],
|
||||
catalog: [],
|
||||
furnidata: [],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: false, bytes: 0 },
|
||||
icon: { exists: false, bytes: 0 },
|
||||
};
|
||||
describe("completeness panel", () => {
|
||||
it("renders five separately named parts with useful missing-data guidance", () => {
|
||||
const html = renderToStaticMarkup(<FurnitureCompleteness local={empty} />);
|
||||
for (const part of ["sql", "catalog", "furnidata", "icon", "nitro"]) {
|
||||
expect(html).toContain(`parts.${part}`);
|
||||
expect(html).toContain(`actions.${part}`);
|
||||
}
|
||||
expect(html).not.toContain('href="/admin/studio/audit"');
|
||||
});
|
||||
it("gives conflicts an accessible action to open the real audit page", () => {
|
||||
const local = {
|
||||
...empty,
|
||||
sql: [
|
||||
{ id: 1, spriteId: 1, type: "s", name: "Chair" },
|
||||
{ id: 2, spriteId: 2, type: "s", name: "Chair" },
|
||||
],
|
||||
};
|
||||
const html = renderToStaticMarkup(<FurnitureCompleteness local={local} />);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/admin/studio/audit"');
|
||||
expect(html).toContain("actions.audit");
|
||||
});
|
||||
it("renders unavailable checks as unverified with a recheck instruction", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FurnitureCompleteness
|
||||
local={{
|
||||
...empty,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/statuses.unknown/g)).toHaveLength(2);
|
||||
expect(html.match(/actions.recheck/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { furnitureCompleteness } from "@/lib/furni/studio-completeness";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
export function FurnitureCompleteness({
|
||||
local,
|
||||
}: {
|
||||
local: FurnitureInspection;
|
||||
}) {
|
||||
const t = useTranslations("admin.studio.completeness");
|
||||
const parts = furnitureCompleteness(local);
|
||||
return (
|
||||
<section aria-label={t("title")} className="space-y-3">
|
||||
<h3 className="font-semibold">{t("title")}</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{parts.map((part) => (
|
||||
<div
|
||||
key={part.id}
|
||||
className="rounded-md border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<dt className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(`parts.${part.id}`)}
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
<p
|
||||
className={
|
||||
part.status === "present"
|
||||
? "font-medium text-[var(--admin-success)]"
|
||||
: part.status === "unknown"
|
||||
? "font-medium text-[var(--admin-text-muted)]"
|
||||
: "font-medium text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
{t(`statuses.${part.status}`)}
|
||||
</p>
|
||||
{part.action && (
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(`actions.${part.action}`)}
|
||||
</p>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{parts.some((part) => part.status === "conflict") && (
|
||||
<p role="alert" className="text-[var(--admin-warning)]">
|
||||
<Link
|
||||
href="/admin/studio/audit"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("openAudit")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
import {
|
||||
Check,
|
||||
CloudDownload,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||
import { FurnitureInspector } from "./furniture-inspector";
|
||||
import {
|
||||
getFurniImageUrl,
|
||||
handleImgError,
|
||||
TYPE_LABELS,
|
||||
} from "./studio-furni-icons";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
|
||||
interface FurnitureDetailDrawerProps {
|
||||
detail: FurniItem;
|
||||
importingId: string | null;
|
||||
regenerating: boolean;
|
||||
busy: boolean;
|
||||
inspectionKey: string;
|
||||
onClose: () => void;
|
||||
onImport: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRegenerate: () => void;
|
||||
onRepair: () => Promise<unknown>;
|
||||
}
|
||||
export function FurnitureDetailDrawer({
|
||||
detail,
|
||||
importingId,
|
||||
regenerating,
|
||||
busy,
|
||||
inspectionKey,
|
||||
onClose,
|
||||
onImport,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onRepair,
|
||||
}: FurnitureDetailDrawerProps) {
|
||||
const t = useTranslations("admin.studio.completeness");
|
||||
const detailPreview = previewAutoCatalog(
|
||||
detail.classname,
|
||||
detail.type === "wallitem" ? "i" : "s",
|
||||
);
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Furni detail
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close furniture details"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(detail)}
|
||||
alt={detail.name}
|
||||
className="h-28 w-28 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => handleImgError(e, detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
|
||||
{detail.classname}
|
||||
</p>
|
||||
|
||||
{detail.description ? (
|
||||
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
|
||||
{detail.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
{TYPE_LABELS[detail.type] ?? detail.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">rev {detail.revision}</Badge>
|
||||
{detail.alreadyImported ? (
|
||||
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
|
||||
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-[var(--admin-accent-foreground)]">
|
||||
<Sparkles size={10} /> Auto-import ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FurnitureInspector
|
||||
key={inspectionKey}
|
||||
item={detail}
|
||||
busy={busy}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
{/* Auto-catalog preview */}
|
||||
{detailPreview && (
|
||||
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
|
||||
Automatic placement
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">Category</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.categoryLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">Price</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.credits} credits
|
||||
{detailPreview.points > 0
|
||||
? ` + ${detailPreview.points} points`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
<div className="space-y-2">
|
||||
{!detail.alreadyImported ? (
|
||||
<Button
|
||||
className="w-full gap-1.5"
|
||||
disabled={importingId !== null}
|
||||
onClick={onImport}
|
||||
>
|
||||
{importingId === detail.classname ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CloudDownload size={14} />
|
||||
)}
|
||||
Import & add to catalog
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!detail.nitroExists && (
|
||||
<>
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is missing.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
disabled={regenerating}
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate .nitro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full gap-1.5"
|
||||
onClick={onEdit}
|
||||
disabled={!detail.nitroExists}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit nitro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.alreadyImported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete imported furni
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type FurnitureInspection,
|
||||
furnitureHealth,
|
||||
} from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureCompleteness } from "./furniture-completeness";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
import { useFurnitureInspection } from "./use-furniture-inspection";
|
||||
|
||||
const status = (value: boolean | null | undefined) =>
|
||||
value == null ? "Not verified" : value ? "Present" : "Missing";
|
||||
export function FurnitureComparison({
|
||||
item,
|
||||
local,
|
||||
@@ -20,42 +19,7 @@ export function FurnitureComparison({
|
||||
const comparison = compareFurniture(item, local);
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
[".nitro", local.nitro.exists],
|
||||
["Local icon", local.icon.exists],
|
||||
[
|
||||
"Furnidata",
|
||||
local.furnidataReadable ? local.furnidata.length > 0 : null,
|
||||
],
|
||||
["SQL item", local.sql.length > 0],
|
||||
["Catalog offer", local.catalog.length > 0],
|
||||
] as const
|
||||
).map(([label, present]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-md border border-[var(--admin-border)] p-2"
|
||||
>
|
||||
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
|
||||
<dd
|
||||
className={
|
||||
present
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
{status(present)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{comparison.state === "conflict" && (
|
||||
<p role="alert" className="text-[var(--admin-warning)]">
|
||||
Multiple local records use this classname. Review them in Catalog
|
||||
audit before importing.
|
||||
</p>
|
||||
)}
|
||||
<FurnitureCompleteness local={local} />
|
||||
{!local.furnidataReadable && (
|
||||
<p className="text-[var(--admin-warning)]">
|
||||
Local furnidata could not be read. Its values are not verified.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
@@ -10,6 +12,7 @@ export function FurnitureJobHistory({
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
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">
|
||||
@@ -21,6 +24,12 @@ export function FurnitureJobHistory({
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
@@ -50,7 +59,7 @@ export function FurnitureJobHistory({
|
||||
aria-label={t("progress")}
|
||||
/>
|
||||
<ul className="space-y-1 py-2">
|
||||
{job.items.map((item) => (
|
||||
{job.items.slice(0, limits[job.id] ?? 50).map((item) => (
|
||||
<li key={item.classname}>
|
||||
<strong>{item.classname}</strong> · {t(item.state)}
|
||||
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
||||
@@ -70,6 +79,46 @@ export function FurnitureJobHistory({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{job.items.length > (limits[job.id] ?? 50) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLimits((current) => ({
|
||||
...current,
|
||||
[job.id]: (current[job.id] ?? 50) + 50,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("showMoreItems")}
|
||||
</Button>
|
||||
)}
|
||||
{(job.state === "queued" || job.state === "running") && (
|
||||
<div className="space-y-2 py-2">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.cancelling !== null || job.cancelRequested}
|
||||
onClick={() => void state.cancel(job.id)}
|
||||
>
|
||||
{t("cancelRemaining")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{job.items.some(
|
||||
(item) =>
|
||||
item.state === "interrupted" ||
|
||||
item.error?.startsWith("Server restarted during import.") ||
|
||||
(item.state === "running" && job.state === "interrupted"),
|
||||
) && (
|
||||
<p role="alert" className="py-2 text-[var(--admin-warning)]">
|
||||
{t("uncertainOutcome")}{" "}
|
||||
<Link href="/admin/studio/audit" className="underline">
|
||||
{t("reviewLocalData")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{retryItems.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -84,7 +133,7 @@ export function FurnitureJobHistory({
|
||||
}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted"
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
? "retryUnfinished"
|
||||
: "retryFailed",
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +38,7 @@ export function ImportReview({
|
||||
busy?: boolean;
|
||||
onConfirm: (items: FurniItem[]) => void;
|
||||
}) {
|
||||
const completenessT = useTranslations("admin.studio.completeness");
|
||||
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
async function attach(item: FurniItem, file: File) {
|
||||
@@ -186,6 +189,22 @@ export function ImportReview({
|
||||
(complete missing data) · {blocked} conflicts ·{" "}
|
||||
{unavailable.length} source file warnings
|
||||
</p>
|
||||
{blocked > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-3 text-sm"
|
||||
>
|
||||
<p>
|
||||
{completenessT("excludedConflicts", { count: blocked })}
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/studio/audit"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
{completenessT("openAudit")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{inspection.items.some(
|
||||
(item) =>
|
||||
!item.furnidataReadable ||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -53,10 +52,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import {
|
||||
type AutoCatalogPreview,
|
||||
previewAutoCatalog,
|
||||
} from "@/lib/furni/auto-catalog";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -64,7 +59,7 @@ import type { TreeNode } from "@/types/catalog";
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
import { CatalogRail } from "./catalog-rail";
|
||||
import { CheckboxDot } from "./checkbox-dot";
|
||||
import { FurnitureInspector } from "./furniture-inspector";
|
||||
import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
|
||||
import { FurnitureJobHistory } from "./furniture-jobs";
|
||||
import { ImportReview } from "./import-review";
|
||||
import { filterFurniture } from "./studio-filters";
|
||||
@@ -883,14 +878,6 @@ export function StudioClient({
|
||||
}
|
||||
}
|
||||
|
||||
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
||||
if (!detail) return null;
|
||||
return previewAutoCatalog(
|
||||
detail.classname,
|
||||
detail.type === "wallitem" ? "i" : "s",
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
const selectedNonImported = useMemo(
|
||||
() =>
|
||||
filteredItems.filter(
|
||||
@@ -1930,190 +1917,43 @@ export function StudioClient({
|
||||
|
||||
{/* ── Detail drawer ─────────────────────────────── */}
|
||||
{detail ? (
|
||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Furni detail
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close furniture details"
|
||||
onClick={() => setDetail(null)}
|
||||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(detail)}
|
||||
alt={detail.name}
|
||||
className="h-28 w-28 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => handleImgError(e, detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
|
||||
{detail.classname}
|
||||
</p>
|
||||
|
||||
{detail.description ? (
|
||||
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
|
||||
{detail.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
{TYPE_LABELS[detail.type] ?? detail.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">rev {detail.revision}</Badge>
|
||||
{detail.alreadyImported ? (
|
||||
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
|
||||
<Check size={10} /> In catalog
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-[var(--admin-accent-foreground)]">
|
||||
<Sparkles size={10} /> Auto-import ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FurnitureInspector
|
||||
key={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
|
||||
.filter((j) => j.state === "completed")
|
||||
.map((j) => j.id)
|
||||
.join(",")}`}
|
||||
item={detail}
|
||||
busy={
|
||||
jobs.busy ||
|
||||
jobs.jobs.some(
|
||||
(j) =>
|
||||
(j.state === "queued" || j.state === "running") &&
|
||||
j.items.some((i) => i.classname === detail.classname),
|
||||
)
|
||||
}
|
||||
onRepair={() =>
|
||||
jobs.submit([detail], {
|
||||
sourceId: activeSource || undefined,
|
||||
translate: false,
|
||||
mode: "repair",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Auto-catalog preview */}
|
||||
{detailPreview && (
|
||||
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<WandSparkles
|
||||
size={12}
|
||||
className="text-[var(--admin-accent)]"
|
||||
/>
|
||||
Automatic placement
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Category
|
||||
</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.categoryLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Price
|
||||
</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.credits} credits
|
||||
{detailPreview.points > 0
|
||||
? ` + ${detailPreview.points} points`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
<div className="space-y-2">
|
||||
{!detail.alreadyImported ? (
|
||||
<Button
|
||||
className="w-full gap-1.5"
|
||||
disabled={importingId !== null}
|
||||
onClick={() => setReview({ items: [detail], single: true })}
|
||||
>
|
||||
{importingId === detail.classname ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CloudDownload size={14} />
|
||||
)}
|
||||
Import & add to catalog
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!detail.nitroExists && (
|
||||
<>
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is
|
||||
missing.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
disabled={regeneratingNitro.has(detail.classname)}
|
||||
onClick={() => regenNitro(detail)}
|
||||
>
|
||||
{regeneratingNitro.has(detail.classname) ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate .nitro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() => setEditingClassname(detail.classname)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit nitro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.alreadyImported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
onClick={() => {
|
||||
setDeleting(detail.classname);
|
||||
setConfirmDelete(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete imported furni
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<FurnitureDetailDrawer
|
||||
detail={detail}
|
||||
importingId={importingId}
|
||||
regenerating={regeneratingNitro.has(detail.classname)}
|
||||
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
|
||||
.filter(
|
||||
(j) =>
|
||||
j.state === "completed" ||
|
||||
j.state === "cancelled" ||
|
||||
j.state === "interrupted",
|
||||
)
|
||||
.map((j) => j.id)
|
||||
.join(",")}`}
|
||||
busy={
|
||||
jobs.busy ||
|
||||
jobs.jobs.some(
|
||||
(j) =>
|
||||
(j.state === "queued" || j.state === "running") &&
|
||||
j.items.some((i) => i.classname === detail.classname),
|
||||
)
|
||||
}
|
||||
onClose={() => setDetail(null)}
|
||||
onImport={() => setReview({ items: [detail], single: true })}
|
||||
onEdit={() => setEditingClassname(detail.classname)}
|
||||
onRegenerate={() => void regenNitro(detail)}
|
||||
onDelete={() => {
|
||||
setDeleting(detail.classname);
|
||||
setConfirmDelete(true);
|
||||
}}
|
||||
onRepair={() =>
|
||||
jobs.submit([detail], {
|
||||
sourceId: activeSource || undefined,
|
||||
translate: false,
|
||||
mode: "repair",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
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) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
@@ -29,7 +33,9 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
if (
|
||||
(job.state === "completed" || job.state === "interrupted") &&
|
||||
(job.state === "completed" ||
|
||||
job.state === "interrupted" ||
|
||||
job.state === "cancelled") &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
@@ -44,7 +50,33 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
useVisiblePolling(refresh, 5000);
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
useVisiblePolling(refresh, active ? 5000 : 30000);
|
||||
const cancel = async (id: string) => {
|
||||
if (cancelInFlight.current) return;
|
||||
cancelInFlight.current = true;
|
||||
setCancelling(id);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
setJobs((current) =>
|
||||
current.map((job) => (job.id === id ? data.job : job)),
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
cancelInFlight.current = false;
|
||||
setCancelling(null);
|
||||
}
|
||||
};
|
||||
const submit = async (
|
||||
items: ImportJobItem[],
|
||||
options: {
|
||||
@@ -88,5 +120,5 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh };
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
bigint,
|
||||
int,
|
||||
longtext,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const ArticleDrafts = mysqlTable(
|
||||
"website_article_drafts",
|
||||
{
|
||||
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
|
||||
articleKey: varchar("article_key", { length: 32 }).notNull(),
|
||||
version: int("version", { unsigned: true }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.userId, table.articleKey] })],
|
||||
);
|
||||
export const ArticleRevisions = mysqlTable("website_article_revisions", {
|
||||
id: bigint("id", { mode: "number", unsigned: true })
|
||||
.autoincrement()
|
||||
.primaryKey(),
|
||||
articleId: bigint("article_id", { mode: "bigint", unsigned: true }).notNull(),
|
||||
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
it("validates the complete 137-row migration matrix without issues", () => {
|
||||
it("validates the complete 139-row migration matrix without issues", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||
expect(discovered).toHaveLength(138);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(discovered).toHaveLength(139);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(138);
|
||||
expect(pages).toHaveLength(139);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 138 legacy pages exactly once", () => {
|
||||
it("covers all 139 legacy pages exactly once", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
||||
] as const;
|
||||
|
||||
describe("systemMigrationEntries", () => {
|
||||
it("covers all 19 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(19);
|
||||
it("covers all 20 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(20);
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||
|
||||
@@ -21,6 +21,21 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
|
||||
}
|
||||
|
||||
export const systemMigrationEntries: readonly MigrationEntry[] = [
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/devops/installation",
|
||||
sourceFile: "src/app/admin/devops/installation/page.tsx",
|
||||
targetPath: "/admin/system/observability/installation",
|
||||
decision: "REBUILD",
|
||||
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
|
||||
dependencies: {
|
||||
queries: ["inspectInstallation", "cms_migrations", "worker heartbeat"],
|
||||
mutations: [],
|
||||
},
|
||||
auditRequirement: "NONE",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
}),
|
||||
// Operator alerts and read-only analytics.
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { migrationState, workerState } from "./installation-state";
|
||||
|
||||
it("distinguishes missing, malformed and stale worker evidence", () => {
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
expect(workerState(null, now)).toBe("unknown");
|
||||
expect(workerState("bad", now)).toBe("unknown");
|
||||
expect(workerState("2026-09-09T11:59:30Z", now)).toBe("ok");
|
||||
expect(workerState("2026-09-09T11:55:00Z", now)).toBe("failed");
|
||||
});
|
||||
it("never treats missing migration history as up to date", () => {
|
||||
expect(migrationState(["0026"], null)).toEqual({
|
||||
state: "unknown",
|
||||
pending: null,
|
||||
});
|
||||
expect(migrationState(["0025", "0026"], ["0025"])).toEqual({
|
||||
state: "failed",
|
||||
pending: 1,
|
||||
});
|
||||
expect(migrationState(["0026"], ["0026"])).toEqual({
|
||||
state: "ok",
|
||||
pending: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type DiagnosticState = "ok" | "failed" | "unknown" | "missing";
|
||||
export function workerState(
|
||||
value: string | null,
|
||||
now = Date.now(),
|
||||
): DiagnosticState {
|
||||
if (!value) return "unknown";
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time) || time > now + 60000) return "unknown";
|
||||
return now - time <= 120000 ? "ok" : "failed";
|
||||
}
|
||||
export function migrationState(
|
||||
expected: string[],
|
||||
applied: string[] | null,
|
||||
): { state: DiagnosticState; pending: number | null } {
|
||||
if (!expected.length || applied === null)
|
||||
return { state: "unknown", pending: null };
|
||||
const done = new Set(applied);
|
||||
const pending = expected.filter((name) => !done.has(name)).length;
|
||||
return { state: pending ? "failed" : "ok", pending };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import "server-only";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
type DiagnosticState,
|
||||
migrationState,
|
||||
workerState,
|
||||
} from "@/lib/admin/installation-state";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { db } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function inspectInstallation() {
|
||||
const started = performance.now();
|
||||
const [health, storage, expected, applied, heartbeat, renderer] =
|
||||
await Promise.all([
|
||||
fetchOpsHealth(),
|
||||
Promise.all(
|
||||
["storage", "public/nitro-assets", "public/swf"].map(async (name) => ({
|
||||
name,
|
||||
state: await access(
|
||||
path.join(process.cwd(), name),
|
||||
constants.R_OK | constants.W_OK,
|
||||
)
|
||||
.then(() => "ok" as const)
|
||||
.catch(() => "failed" as const),
|
||||
})),
|
||||
),
|
||||
readdir(path.join(process.cwd(), "drizzle/migrations"))
|
||||
.then((files) =>
|
||||
files
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.map((name) => name.slice(0, -4)),
|
||||
)
|
||||
.catch(() => []),
|
||||
db
|
||||
.execute(sql`SELECT migration FROM cms_migrations`)
|
||||
.then(([rows]) =>
|
||||
(rows as unknown as { migration: string }[]).map(
|
||||
(row) => row.migration,
|
||||
),
|
||||
)
|
||||
.catch(() => null),
|
||||
redis
|
||||
? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
siteSettings.get("nitro_client_url", "").catch(() => ""),
|
||||
]);
|
||||
const migration = migrationState(expected, applied);
|
||||
const release = process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown";
|
||||
const rows: Array<{ key: string; state: DiagnosticState; detail?: string }> =
|
||||
[
|
||||
{
|
||||
key: "release",
|
||||
state: /^[a-f0-9]{40}$/i.test(release) ? "ok" : "unknown",
|
||||
detail: /^[a-f0-9]{40}$/i.test(release) ? release : undefined,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
state: health.dbOk ? "ok" : "failed",
|
||||
detail: health.dbOk ? `${health.dbLatencyMs} ms` : undefined,
|
||||
},
|
||||
{
|
||||
key: "redis",
|
||||
state:
|
||||
health.redisOk === null
|
||||
? "missing"
|
||||
: health.redisOk
|
||||
? "ok"
|
||||
: "failed",
|
||||
},
|
||||
{ key: "emulator", state: health.emulatorOk ? "ok" : "failed" },
|
||||
{
|
||||
key: "storage",
|
||||
state: storage.every((item) => item.state === "ok") ? "ok" : "failed",
|
||||
detail:
|
||||
storage
|
||||
.filter((item) => item.state !== "ok")
|
||||
.map((item) => item.name)
|
||||
.join(", ") || undefined,
|
||||
},
|
||||
{
|
||||
key: "migrations",
|
||||
state: migration.state,
|
||||
detail:
|
||||
migration.pending === null ? undefined : String(migration.pending),
|
||||
},
|
||||
{
|
||||
key: "worker",
|
||||
state: workerState(heartbeat),
|
||||
detail:
|
||||
heartbeat && Number.isFinite(Date.parse(heartbeat))
|
||||
? new Date(heartbeat).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "renderer",
|
||||
state: health.dbOk ? "ok" : "unknown",
|
||||
detail: health.dbOk && !renderer ? "/nitro-client/" : undefined,
|
||||
},
|
||||
{ key: "registry", state: "unknown" },
|
||||
];
|
||||
return {
|
||||
rows,
|
||||
checkedAt: new Date().toISOString(),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { collectOperations } from "./operations-inbox";
|
||||
|
||||
it("does not load unauthorized sources and reports failed sources separately", async () => {
|
||||
const denied = vi.fn();
|
||||
const result = await collectOperations([
|
||||
{ source: "errors", allowed: false, load: denied },
|
||||
{
|
||||
source: "imports",
|
||||
allowed: true,
|
||||
load: async () => {
|
||||
throw Error("offline");
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(denied).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ items: [], unavailable: ["imports"] });
|
||||
});
|
||||
it("deduplicates, prioritizes and excludes empty work", async () => {
|
||||
const item = {
|
||||
id: "one",
|
||||
source: "errors" as const,
|
||||
href: "/admin/devops/cms-errors",
|
||||
count: 2,
|
||||
severity: 3,
|
||||
};
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: true,
|
||||
load: async () => [item, item, { ...item, id: "empty", count: 0 }],
|
||||
},
|
||||
]);
|
||||
expect(result.items).toEqual([item]);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type OperationsSource =
|
||||
| "errors"
|
||||
| "imports"
|
||||
| "tickets"
|
||||
| "publications";
|
||||
export interface OperationsItem {
|
||||
id: string;
|
||||
source: OperationsSource;
|
||||
href: string;
|
||||
count: number;
|
||||
severity: number;
|
||||
}
|
||||
export interface OperationsLoader {
|
||||
source: OperationsSource;
|
||||
allowed: boolean;
|
||||
load: () => Promise<OperationsItem[]>;
|
||||
}
|
||||
/** Authorization precedes loading; a failed source is never reported as empty. */
|
||||
export async function collectOperations(loaders: OperationsLoader[]) {
|
||||
const available = loaders.filter((loader) => loader.allowed);
|
||||
const results = await Promise.allSettled(
|
||||
available.map((loader) => loader.load()),
|
||||
);
|
||||
const unique = new Map<string, OperationsItem>();
|
||||
const unavailable: OperationsSource[] = [];
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
unavailable.push(available[index].source);
|
||||
continue;
|
||||
}
|
||||
for (const item of result.value) {
|
||||
const key = `${item.source}:${item.id}`;
|
||||
if (item.count > 0 && !unique.has(key)) unique.set(key, item);
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [...unique.values()]
|
||||
.sort((a, b) => b.severity - a.severity || a.id.localeCompare(b.id))
|
||||
.slice(0, 12),
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
+40
-37
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import { env } from "@/env";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -19,40 +20,42 @@ export type OpsHealth = {
|
||||
* Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs.
|
||||
* Semantics match `/api/health` for Redis (null when not configured).
|
||||
*/
|
||||
export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
}
|
||||
})(),
|
||||
(async (): Promise<boolean | null> => {
|
||||
if (!env.REDIS_URL) return null;
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const pong = await redis.ping();
|
||||
return pong === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return {
|
||||
dbOk: dbProbe.dbOk,
|
||||
dbLatencyMs: dbProbe.dbLatencyMs,
|
||||
redisOk,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
}
|
||||
export const fetchOpsHealth = cache(
|
||||
async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
}
|
||||
})(),
|
||||
(async (): Promise<boolean | null> => {
|
||||
if (!env.REDIS_URL) return null;
|
||||
if (!redis) return false;
|
||||
try {
|
||||
const pong = await redis.ping();
|
||||
return pong === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return {
|
||||
dbOk: dbProbe.dbOk,
|
||||
dbLatencyMs: dbProbe.dbLatencyMs,
|
||||
redisOk,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleKey, readArticleDraft } from "./article-draft";
|
||||
import { articleEditToken } from "./article-edit-token";
|
||||
|
||||
it("allows incomplete drafts without requiring publication-ready fields", () => {
|
||||
const form = new FormData();
|
||||
form.set("status", "scheduled");
|
||||
expect(readArticleDraft(form)).toMatchObject({
|
||||
title: "",
|
||||
fullStory: "",
|
||||
publishAt: "",
|
||||
status: "scheduled",
|
||||
});
|
||||
});
|
||||
it("rejects unbounded or malformed recovery keys", () => {
|
||||
for (const key of ["", "../1", "0", "-1", "1e5", "1".repeat(21)])
|
||||
expect(() => articleKey(key)).toThrow();
|
||||
expect(articleKey("new")).toBe("new");
|
||||
expect(articleKey("123")).toBe("123");
|
||||
});
|
||||
it("detects body and scheduled instant changes even within the same clock second", () => {
|
||||
const row = {
|
||||
title: "News",
|
||||
slug: "news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "old",
|
||||
status: "scheduled",
|
||||
publishAt: new Date("2030-01-01T00:00:00Z"),
|
||||
};
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, fullStory: "new" }),
|
||||
);
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, publishAt: new Date("2030-01-01T01:00:00Z") }),
|
||||
);
|
||||
expect(articleEditToken(row)).toBe(articleEditToken({ ...row }));
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ArticleDraft = {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: string;
|
||||
baseToken: string;
|
||||
};
|
||||
export function readArticleDraft(form: FormData): ArticleDraft {
|
||||
const keys = [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"baseToken",
|
||||
] as const;
|
||||
const result = {} as ArticleDraft;
|
||||
for (const key of keys) {
|
||||
const value = form.get(key) ?? "";
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > (key === "fullStory" ? 500_000 : 255)
|
||||
)
|
||||
throw new Error("draftInvalid");
|
||||
result[key] = value;
|
||||
}
|
||||
if (!["draft", "scheduled", "published"].includes(result.status))
|
||||
throw new Error("draftInvalid");
|
||||
return result;
|
||||
}
|
||||
export function articleKey(value: string): string {
|
||||
if (value === "new" || /^[1-9]\d{0,19}$/.test(value)) return value;
|
||||
throw new Error("draftInvalid");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHash } from "node:crypto";
|
||||
export function articleEditToken(article: {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: Date | null;
|
||||
}) {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
article.title,
|
||||
article.slug,
|
||||
article.image,
|
||||
article.shortStory,
|
||||
article.fullStory,
|
||||
article.status,
|
||||
article.publishAt?.toISOString() ?? "",
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -3,6 +3,8 @@ export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "articleNotFound"
|
||||
| "editConflict"
|
||||
| "titleRequired"
|
||||
| "titleTooLong"
|
||||
| "summaryTooLong"
|
||||
|
||||
@@ -78,10 +78,22 @@ describe("deployment transaction", () => {
|
||||
result.calls.indexOf("docker stop"),
|
||||
);
|
||||
expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan(
|
||||
result.calls.indexOf("docker tag sha256:new epicnext-cms:latest"),
|
||||
);
|
||||
expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan(
|
||||
result.calls.indexOf(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
),
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
`docker image rm epicnext-cms:verified-${"c".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${"b".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
"docker tag sha256:old epicnext-cms:previous",
|
||||
);
|
||||
@@ -97,9 +109,12 @@ describe("deployment transaction", () => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.app).toBe("old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.output).toContain("Rollback verified: sha256:old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
"docker tag sha256:new epicnext-cms:latest",
|
||||
);
|
||||
},
|
||||
30000,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { errorOperationLink, summarizeErrorGroup } from "./error-groups";
|
||||
import { createErrorRecord } from "./error-monitor";
|
||||
|
||||
it("counts retained occurrences and identified users across releases without trusting browser identities", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", { userId: 12 });
|
||||
const first = { ...base, at: "2026-09-08T10:00:00Z", release: "a" };
|
||||
const latest = { ...base, at: "2026-09-09T10:00:00Z", release: "b" };
|
||||
const browser = {
|
||||
...latest,
|
||||
source: "browser" as const,
|
||||
context: { userId: 99 },
|
||||
};
|
||||
const summary = summarizeErrorGroup([
|
||||
latest,
|
||||
browser,
|
||||
first,
|
||||
{ ...latest, context: { userId: "12" } },
|
||||
{ ...latest, context: { email: "redacted", userId: "[REDACTED]" } },
|
||||
]);
|
||||
expect(summary.count).toBe(5);
|
||||
expect(summary.firstAt).toBe(first.at);
|
||||
expect(summary.lastAt).toBe(latest.at);
|
||||
expect(summary.affectedUsers).toBe(1);
|
||||
expect(summary.unidentified).toBe(2);
|
||||
expect(summary.releases.map((r) => [r.release, r.count])).toEqual([
|
||||
["a", 1],
|
||||
["b", 4],
|
||||
]);
|
||||
});
|
||||
it("creates only recognized local operation links", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", {
|
||||
module: "news",
|
||||
articleId: 7,
|
||||
});
|
||||
expect(errorOperationLink(base)).toBe("/admin/articles/7");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
context: { module: "news", articleId: "../settings" },
|
||||
}),
|
||||
).toBe("/admin/articles");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
source: "browser",
|
||||
context: { path: "/admin/media" },
|
||||
}),
|
||||
).toBe("/admin/media");
|
||||
for (const path of [
|
||||
"https://evil.invalid",
|
||||
"//evil.invalid",
|
||||
"/admin/articles/1?token=x",
|
||||
"/admin/articles/../settings",
|
||||
"/admin/users/42",
|
||||
"javascript:alert(1)",
|
||||
"/admin/%61rticles",
|
||||
])
|
||||
expect(errorOperationLink({ ...base, context: { path } })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CmsErrorRecord } from "./error-monitor";
|
||||
|
||||
export function errorOperationLink(record: CmsErrorRecord): string | null {
|
||||
const context = record.context;
|
||||
if (record.source === "server") {
|
||||
if (context.module === "news") {
|
||||
const id = String(context.articleId ?? "");
|
||||
return /^[1-9]\d{0,18}$/.test(id)
|
||||
? `/admin/articles/${id}`
|
||||
: "/admin/articles";
|
||||
}
|
||||
const routes: Record<string, string> = {
|
||||
catalog: "/admin/catalog",
|
||||
media: "/admin/media",
|
||||
users: "/admin/users",
|
||||
settings: "/admin/settings",
|
||||
studio: "/admin/studio",
|
||||
};
|
||||
if (
|
||||
typeof context.module === "string" &&
|
||||
Object.hasOwn(routes, context.module)
|
||||
)
|
||||
return routes[context.module];
|
||||
}
|
||||
// Never use arbitrary error-provided URLs as navigation targets.
|
||||
const candidate = context.path;
|
||||
if (typeof candidate !== "string") return null;
|
||||
if (
|
||||
/^\/admin\/(articles|users|catalog|media|settings|studio)$/.test(candidate)
|
||||
)
|
||||
return candidate;
|
||||
if (/^\/admin\/articles\/[1-9]\d{0,18}$/.test(candidate)) return candidate;
|
||||
return null;
|
||||
}
|
||||
export function summarizeErrorGroup(events: CmsErrorRecord[]) {
|
||||
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
|
||||
const first = sorted[0];
|
||||
const latest = sorted.at(-1);
|
||||
const users = new Set<string>();
|
||||
let unidentified = 0;
|
||||
const releases = new Map<
|
||||
string,
|
||||
{ release: string; count: number; firstAt: string; lastAt: string }
|
||||
>();
|
||||
for (const event of sorted) {
|
||||
const raw = event.source === "server" ? event.context.userId : null;
|
||||
const id =
|
||||
typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0
|
||||
? String(raw)
|
||||
: typeof raw === "string" && /^[1-9]\d{0,14}$/.test(raw)
|
||||
? raw
|
||||
: null;
|
||||
if (id) users.add(id);
|
||||
else unidentified++;
|
||||
const prior = releases.get(event.release);
|
||||
releases.set(event.release, {
|
||||
release: event.release,
|
||||
count: (prior?.count ?? 0) + 1,
|
||||
firstAt: prior?.firstAt ?? event.at,
|
||||
lastAt: event.at,
|
||||
});
|
||||
}
|
||||
return {
|
||||
count: events.length,
|
||||
firstAt: first?.at ?? null,
|
||||
lastAt: latest?.at ?? null,
|
||||
affectedUsers: users.size,
|
||||
unidentified,
|
||||
releases: [...releases.values()],
|
||||
latest,
|
||||
operationLink: latest ? errorOperationLink(latest) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -54,3 +54,50 @@ it("resolves a group and reopens it when a later occurrence arrives", async () =
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists assignments across instances and clears them without modifying resolution", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const store = new ErrorStore(dir);
|
||||
const record = createErrorRecord("server", "assign", "failed");
|
||||
await store.append(record);
|
||||
await store.resolve(record.fingerprint);
|
||||
await store.assign(record.fingerprint, 42);
|
||||
const persisted = (await new ErrorStore(dir).read()).records[0];
|
||||
expect(persisted.assignment?.userId).toBe(42);
|
||||
expect(persisted.resolved).toBe(true);
|
||||
await new ErrorStore(dir).assign(record.fingerprint, null);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
await expect(store.assign("../escape", 42)).rejects.toThrow(
|
||||
"Invalid fingerprint",
|
||||
);
|
||||
await expect(store.assign(record.fingerprint, -1)).rejects.toThrow(
|
||||
"Invalid assignee",
|
||||
);
|
||||
await expect(store.assign("0000000000000000", 42)).rejects.toThrow(
|
||||
"no longer available",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("ignores expired assignment metadata and prunes it with error retention", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const record = createErrorRecord("server", "retention", "failed");
|
||||
const expired = `2000-01-01.${record.fingerprint}.assignment`;
|
||||
await writeFile(
|
||||
path.join(dir, expired),
|
||||
JSON.stringify({ userId: 99, at: "2000-01-01T00:00:00Z" }),
|
||||
);
|
||||
const store = new ErrorStore(dir);
|
||||
await store.append(record);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
expect(await readdir(dir)).not.toContain(expired);
|
||||
const current = `${record.at.slice(0, 10)}.${record.fingerprint}.assignment`;
|
||||
await writeFile(path.join(dir, current), "x".repeat(513));
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
@@ -27,6 +28,7 @@ export function redactErrorText(value: string): string {
|
||||
export interface CmsErrorRecord {
|
||||
id: string;
|
||||
resolved?: boolean;
|
||||
assignment?: { userId: number; at: string } | null;
|
||||
at: string;
|
||||
source: "server" | "browser";
|
||||
event: string;
|
||||
@@ -111,7 +113,9 @@ export class ErrorStore {
|
||||
.slice(0, 10);
|
||||
for (const name of files)
|
||||
if (
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.(?:resolved|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.test(
|
||||
name,
|
||||
) &&
|
||||
name.slice(0, 10) < cutoff
|
||||
)
|
||||
await unlink(path.join(this.directory, name)).catch(() => {});
|
||||
@@ -135,6 +139,43 @@ export class ErrorStore {
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
async assign(fingerprint: string, userId: number | null) {
|
||||
if (!/^[a-f0-9]{16}$/.test(fingerprint))
|
||||
throw new Error("Invalid fingerprint");
|
||||
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
|
||||
throw new Error("Invalid assignee");
|
||||
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
|
||||
ErrorStore.pending++;
|
||||
const write = ErrorStore.queue.then(async () => {
|
||||
const { records } = await this.read();
|
||||
if (!records.some((record) => record.fingerprint === fingerprint))
|
||||
throw new Error("Error group no longer available");
|
||||
const at = new Date().toISOString();
|
||||
const file = path.join(
|
||||
this.directory,
|
||||
`${at.slice(0, 10)}.${fingerprint}.assignment`,
|
||||
);
|
||||
const metadata = (await readdir(this.directory)).filter(
|
||||
(name) =>
|
||||
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
|
||||
);
|
||||
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
|
||||
throw new Error("Daily assignment storage limit reached");
|
||||
const temp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
|
||||
await rename(temp, file);
|
||||
} finally {
|
||||
await unlink(temp).catch(() => {});
|
||||
}
|
||||
});
|
||||
ErrorStore.queue = write.catch(() => {});
|
||||
try {
|
||||
await write;
|
||||
} finally {
|
||||
ErrorStore.pending--;
|
||||
}
|
||||
}
|
||||
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
|
||||
const files = await readdir(this.directory).catch(
|
||||
(e: NodeJS.ErrnoException) => {
|
||||
@@ -142,17 +183,48 @@ export class ErrorStore {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const file = path.join(this.directory, name);
|
||||
if ((await stat(file)).size > 512) continue;
|
||||
try {
|
||||
const value = JSON.parse(await readFile(file, "utf8"));
|
||||
if (
|
||||
typeof value.at !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.at))
|
||||
)
|
||||
continue;
|
||||
if (value.userId === null) assignments.set(name.split(".")[1], null);
|
||||
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
|
||||
assignments.set(name.split(".")[1], {
|
||||
userId: value.userId,
|
||||
at: value.at,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore interrupted or malformed metadata. */
|
||||
}
|
||||
}
|
||||
const resolved = new Map<string, string>();
|
||||
for (const name of files
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const at = await readFile(path.join(this.directory, name), "utf8");
|
||||
resolved.set(name.split(".")[1], at);
|
||||
}
|
||||
const records: CmsErrorRecord[] = [];
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
|
||||
@@ -169,6 +241,7 @@ export class ErrorStore {
|
||||
records.push({
|
||||
...r,
|
||||
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
|
||||
assignment: assignments.get(r.fingerprint) ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore an interrupted final write. */
|
||||
|
||||
@@ -5,7 +5,14 @@ import { retryableJobItems } from "./import-job-retry";
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
items: [
|
||||
"done",
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
@@ -14,12 +21,33 @@ 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", () =>
|
||||
it("recovers only safe unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
|
||||
it("retries cancelled remaining work without redoing completed or uncertain items", () => {
|
||||
expect(retryableJobItems(job("cancelled")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not blindly retry legacy interrupted mutations stored as failures", () => {
|
||||
const interrupted = job("interrupted");
|
||||
interrupted.items = [
|
||||
{
|
||||
...interrupted.items[0],
|
||||
state: "failed",
|
||||
error:
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.",
|
||||
},
|
||||
];
|
||||
expect(retryableJobItems(interrupted)).toEqual([]);
|
||||
});
|
||||
@@ -4,7 +4,9 @@ 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"),
|
||||
(item.state === "failed" &&
|
||||
!item.error?.startsWith("Server restarted during import.")) ||
|
||||
((job.state === "interrupted" || job.state === "cancelled") &&
|
||||
(item.state === "pending" || item.state === "cancelled")),
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,20 @@ export interface ImportJob {
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
state: "queued" | "running" | "completed" | "interrupted";
|
||||
state: "queued" | "running" | "completed" | "interrupted" | "cancelled";
|
||||
cancelRequested?: boolean;
|
||||
sourceId?: string;
|
||||
translate: boolean;
|
||||
langs?: string[];
|
||||
items: Array<
|
||||
ImportJobItem & {
|
||||
state: "pending" | "running" | "done" | "failed";
|
||||
state:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "interrupted";
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { furnitureCompleteness } from "./studio-completeness";
|
||||
import {
|
||||
compareFurniture,
|
||||
type FurnitureInspection,
|
||||
} from "./studio-inspection";
|
||||
|
||||
const complete: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [{ id: 100, spriteId: 200, type: "s", name: "Chair" }],
|
||||
catalog: [{ id: 50, pageId: 1, credits: 3, points: 0 }],
|
||||
furnidata: [
|
||||
{ id: 200, type: "flooritem", name: "Chair", description: "", revision: 1 },
|
||||
],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: true, bytes: 200 },
|
||||
icon: { exists: true, bytes: 30 },
|
||||
};
|
||||
describe("furniture completeness", () => {
|
||||
it("checks all five independent parts and accepts locally remapped IDs", () => {
|
||||
expect(furnitureCompleteness(complete).map((p) => p.status)).toEqual(
|
||||
Array(5).fill("present"),
|
||||
);
|
||||
});
|
||||
it("does not infer a catalog offer or icon from SQL and Nitro", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [],
|
||||
icon: { exists: false, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "catalog")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "catalog",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "icon",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "sql")?.status).toBe("present");
|
||||
});
|
||||
it("keeps unreadable assets unknown and directs operators to recheck", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "furnidata")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ id: 201, type: "flooritem" },
|
||||
{ id: 200, type: "wallitem" },
|
||||
])(
|
||||
"flags ID or type mismatches in both review and completeness: %j",
|
||||
(change) => {
|
||||
const local = {
|
||||
...complete,
|
||||
furnidata: [{ ...complete.furnidata[0], ...change }],
|
||||
};
|
||||
expect(
|
||||
furnitureCompleteness(local)
|
||||
.filter((p) => p.status === "conflict")
|
||||
.map((p) => p.id),
|
||||
).toEqual(["sql", "furnidata"]);
|
||||
expect(
|
||||
compareFurniture(
|
||||
{ name: "Chair", description: "", revision: 1, type: "flooritem" },
|
||||
local,
|
||||
).state,
|
||||
).toBe("conflict");
|
||||
},
|
||||
);
|
||||
it("allows multiple legitimate catalog offers but surfaces duplicate SQL records", () => {
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [...complete.catalog, { ...complete.catalog[0], id: 51 }],
|
||||
}).find((p) => p.id === "catalog")?.status,
|
||||
).toBe("present");
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
sql: [...complete.sql, { ...complete.sql[0], id: 101 }],
|
||||
}).find((p) => p.id === "sql"),
|
||||
).toMatchObject({ status: "conflict", action: "audit" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
type FurnitureInspection,
|
||||
hasFurnitureConflict,
|
||||
} from "./studio-inspection";
|
||||
export type FurniturePartId =
|
||||
| "sql"
|
||||
| "catalog"
|
||||
| "furnidata"
|
||||
| "icon"
|
||||
| "nitro";
|
||||
export interface FurniturePart {
|
||||
id: FurniturePartId;
|
||||
status: "present" | "missing" | "unknown" | "conflict";
|
||||
action: FurniturePartId | "recheck" | "audit" | null;
|
||||
}
|
||||
/** Report each required part without treating unavailable checks as missing data. */
|
||||
export function furnitureCompleteness(
|
||||
local: FurnitureInspection,
|
||||
): FurniturePart[] {
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const evidence: Array<[FurniturePartId, boolean | null]> = [
|
||||
["sql", local.sql.length > 0],
|
||||
["catalog", local.catalog.length > 0],
|
||||
["furnidata", local.furnidataReadable ? local.furnidata.length > 0 : null],
|
||||
["icon", local.icon.exists],
|
||||
["nitro", local.nitro.exists],
|
||||
];
|
||||
return evidence.map(([id, present]) => {
|
||||
if (conflict && (id === "sql" || id === "furnidata"))
|
||||
return { id, status: "conflict", action: "audit" };
|
||||
if (present === null) return { id, status: "unknown", action: "recheck" };
|
||||
return {
|
||||
id,
|
||||
status: present ? "present" : "missing",
|
||||
action: present ? null : id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -56,16 +56,15 @@ export function compareFurniture(
|
||||
after !== undefined &&
|
||||
String(before) !== String(after),
|
||||
}));
|
||||
const state =
|
||||
local.sql.length > 1 || local.furnidata.length > 1
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
const state = hasFurnitureConflict(local)
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
return { state, fields } as const;
|
||||
}
|
||||
export function furnitureHealth(local: FurnitureInspection) {
|
||||
@@ -76,17 +75,7 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
missing.push("Furnidata");
|
||||
if (!local.sql.length) missing.push("SQL item");
|
||||
if (!local.catalog.length) missing.push("Catalog offer");
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
const conflict =
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
);
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const unknown =
|
||||
!local.furnidataReadable ||
|
||||
local.nitro.exists === null ||
|
||||
@@ -102,3 +91,18 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
: "incomplete";
|
||||
return { state, missing } as const;
|
||||
}
|
||||
|
||||
export function hasFurnitureConflict(local: FurnitureInspection): boolean {
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
return (
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((dir) => [
|
||||
join(dir, "bash.exe"),
|
||||
join(dirname(dir), "bin", "bash.exe"),
|
||||
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||
])
|
||||
.find((path) => existsSync(path)) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "a".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-publish-test-"));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
bash,
|
||||
[resolve(root, "scripts/publish-container.sh")],
|
||||
{
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/publish-container-harness.sh"),
|
||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
REGISTRY_SERVER: "https://registry.invalid",
|
||||
REGISTRY_REPOSITORY: "owner/cms",
|
||||
REGISTRY_USER: "fixture",
|
||||
REGISTRY_TOKEN: "fixture-only",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
return readFileSync(join(dir, "calls"), "utf8");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
describe("verified application image reuse", () => {
|
||||
it("reuses only the exact image digest that passed deployment checks", () => {
|
||||
const calls = simulate("verified");
|
||||
expect(calls).toContain(
|
||||
`docker tag sha256:candidate registry.invalid/owner/cms:${sha}`,
|
||||
);
|
||||
expect(calls).not.toContain("docker build --network=host --build-arg");
|
||||
expect(
|
||||
calls.indexOf("verify scripts/verify-portable-image.mjs"),
|
||||
).toBeLessThan(calls.indexOf("docker push"));
|
||||
});
|
||||
it.each(["missing", "mismatch"])(
|
||||
"builds committed source when verification marker is %s",
|
||||
(scenario) => {
|
||||
const calls = simulate(scenario);
|
||||
expect(calls).toContain("docker build --network=host --build-arg");
|
||||
expect(calls).not.toContain("docker tag sha256:candidate");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
total: 55,
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadArticleList } from "./article-list";
|
||||
|
||||
describe("article listing", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
state.total = 55;
|
||||
});
|
||||
it("filters drafts before pagination and binds search text", async () => {
|
||||
const result = await loadArticleList({
|
||||
status: "draft",
|
||||
search: "needle'",
|
||||
page: 2,
|
||||
});
|
||||
expect(result.page).toBe(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).toContain("`website_articles`.`status` = ?");
|
||||
expect(query.sql).not.toContain("needle");
|
||||
expect(query.params.slice(0, 3)).toEqual([
|
||||
"draft",
|
||||
"%needle'%",
|
||||
"%needle'%",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 24]);
|
||||
});
|
||||
it("clamps an out-of-range page to the last filtered page", async () => {
|
||||
const result = await loadArticleList({ status: "scheduled", page: 999 });
|
||||
expect(result).toMatchObject({ page: 3, lastPage: 3, total: 55 });
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 48]);
|
||||
});
|
||||
it("bounds invalid input and keeps an empty collection on page one", async () => {
|
||||
state.total = 0;
|
||||
const result = await loadArticleList({ status: "unexpected", page: NaN });
|
||||
expect(result).toMatchObject({
|
||||
status: "all",
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
total: 0,
|
||||
});
|
||||
expect(state.queries[1].sql).not.toContain(
|
||||
"`website_articles`.`status` = ?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
export async function loadArticleList(
|
||||
options: { status?: string; search?: string; page?: number } = {},
|
||||
) {
|
||||
const status = ["draft", "published", "scheduled"].includes(
|
||||
options.status ?? "",
|
||||
)
|
||||
? options.status
|
||||
: "all";
|
||||
const search = (options.search ?? "").trim().slice(0, 191);
|
||||
const where = and(
|
||||
status !== "all" ? eq(WebsiteArticles.status, status ?? "all") : undefined,
|
||||
search
|
||||
? or(
|
||||
like(WebsiteArticles.title, `%${search}%`),
|
||||
like(WebsiteArticles.shortStory, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
const totals = await db
|
||||
.select({ value: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(where);
|
||||
const total = Number(totals[0]?.value ?? 0);
|
||||
const perPage = 24;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(
|
||||
lastPage,
|
||||
Number.isFinite(options.page)
|
||||
? Math.max(1, Math.trunc(options.page ?? 1))
|
||||
: 1,
|
||||
);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
status: WebsiteArticles.status,
|
||||
author: User.username,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.leftJoin(User, eq(User.id, WebsiteArticles.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(WebsiteArticles.createdAt), desc(WebsiteArticles.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage);
|
||||
return { rows, page, perPage, total, lastPage, status, search };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
type Change = { key: string; from: unknown; to: unknown };
|
||||
const sensitive =
|
||||
/password|secret|token|otp|recovery|authTicket|two_factor|api_key/i;
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 10) return "[…]";
|
||||
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
||||
if (!record(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
sensitive.test(key) ? "[Redacted]" : redact(item, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
function parse(value: string) {
|
||||
if (value.length > 100000) throw new Error("oversize");
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!record(parsed)) throw new Error("invalid");
|
||||
return parsed;
|
||||
}
|
||||
export function readAuditChanges(
|
||||
diff: string | null,
|
||||
before?: string | null,
|
||||
after?: string | null,
|
||||
) {
|
||||
try {
|
||||
let changes: Change[];
|
||||
if (diff) {
|
||||
const parsed = parse(diff);
|
||||
changes = Object.entries(parsed).map(([key, value]) => {
|
||||
if (!record(value) || !("from" in value || "to" in value))
|
||||
throw new Error("invalid");
|
||||
return { key, from: value.from, to: value.to };
|
||||
});
|
||||
} else {
|
||||
const previous = before ? parse(before) : {};
|
||||
const next = after ? parse(after) : {};
|
||||
changes = [...new Set([...Object.keys(previous), ...Object.keys(next)])]
|
||||
.filter(
|
||||
(key) => JSON.stringify(previous[key]) !== JSON.stringify(next[key]),
|
||||
)
|
||||
.map((key) => ({ key, from: previous[key], to: next[key] }));
|
||||
}
|
||||
return {
|
||||
invalid: false,
|
||||
changes: changes.map(({ key, from, to }) => ({
|
||||
key,
|
||||
from: sensitive.test(key) ? "[Redacted]" : redact(from),
|
||||
to: sensitive.test(key) ? "[Redacted]" : redact(to),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { invalid: true, changes: [] as Change[] };
|
||||
}
|
||||
}
|
||||
export function formatAuditValue(value: unknown) {
|
||||
const text =
|
||||
value === undefined
|
||||
? "∅"
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2);
|
||||
return text.length > 4000 ? `${text.slice(0, 4000)}…` : text;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface AuditFilters {
|
||||
search?: string;
|
||||
actor?: string;
|
||||
action?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
function day(value?: string) {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return "";
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(date.getTime()) &&
|
||||
date.toISOString().slice(0, 10) === value
|
||||
? value
|
||||
: "";
|
||||
}
|
||||
export function normalizeAuditFilters(options: AuditFilters = {}) {
|
||||
const from = day(options.from);
|
||||
const to = day(options.to);
|
||||
return {
|
||||
search: (options.search ?? "").trim().slice(0, 191),
|
||||
actor: (options.actor ?? "").trim().slice(0, 191),
|
||||
action: (options.action ?? "").trim().slice(0, 191),
|
||||
from,
|
||||
to,
|
||||
until: to
|
||||
? new Date(new Date(`${to}T00:00:00Z`).getTime() + 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: "",
|
||||
page: Number.isFinite(options.page)
|
||||
? Math.min(1000000, Math.max(1, Math.trunc(options.page ?? 1)))
|
||||
: 1,
|
||||
perPage: Number.isFinite(options.perPage)
|
||||
? Math.min(100, Math.max(1, Math.trunc(options.perPage ?? 20)))
|
||||
: 20,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { getAuditLogs } from "./audit";
|
||||
|
||||
describe("audit query filters", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
});
|
||||
it("binds actor/action/date/search values in both list and count queries", async () => {
|
||||
await getAuditLogs({
|
||||
actor: "Alice' OR 1=1 --",
|
||||
action: "update",
|
||||
search: "user",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
perPage: 10,
|
||||
page: 2,
|
||||
});
|
||||
expect(state.queries).toHaveLength(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).not.toContain("Alice");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`user_id` in (select");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` >= ?");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` < ?");
|
||||
expect(query.params.slice(0, 6)).toEqual([
|
||||
"%user%",
|
||||
"%user%",
|
||||
"update",
|
||||
"Alice' OR 1=1 --",
|
||||
"2026-09-09",
|
||||
"2026-09-10",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[0].params.slice(-2)).toEqual([10, 10]);
|
||||
});
|
||||
it("uses an exact numeric actor ID and caps row count", async () => {
|
||||
await getAuditLogs({ actor: "42", perPage: 1000, page: -2 });
|
||||
expect(state.queries[0].params).toEqual([42, 100]);
|
||||
expect(state.queries[0].sql).not.toContain("in (select");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditValue, readAuditChanges } from "./audit-diff";
|
||||
import { normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
describe("audit filter bounds", () => {
|
||||
it("normalizes pagination and rejects invalid calendar dates", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
page: NaN,
|
||||
perPage: Infinity,
|
||||
from: "2026-02-30",
|
||||
to: "x",
|
||||
}),
|
||||
).toMatchObject({ page: 1, perPage: 20, from: "", to: "" });
|
||||
expect(normalizeAuditFilters({ page: -3, perPage: 1000 })).toMatchObject({
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
});
|
||||
it("caps strings and preserves exact actor/action and UTC day boundaries", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
actor: " Alice ",
|
||||
action: " update ",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
}),
|
||||
).toMatchObject({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
until: "2026-09-10",
|
||||
});
|
||||
expect(
|
||||
normalizeAuditFilters({ search: "x".repeat(500) }).search,
|
||||
).toHaveLength(191);
|
||||
});
|
||||
});
|
||||
describe("legacy audit diffs", () => {
|
||||
it.each(["null", "[]", "1", "bad JSON", '{"name":null}', '{"name":"value"}'])(
|
||||
"handles %s safely",
|
||||
(diff) => {
|
||||
expect(readAuditChanges(diff).invalid).toBe(true);
|
||||
},
|
||||
);
|
||||
it("keeps objects and explicit null distinct from absent values", () => {
|
||||
const result = readAuditChanges(
|
||||
'{"settings":{"from":{"enabled":false},"to":null},"added":{"to":3}}',
|
||||
);
|
||||
expect(result.changes).toHaveLength(2);
|
||||
expect(formatAuditValue(result.changes[0].from)).toContain(
|
||||
'"enabled": false',
|
||||
);
|
||||
expect(formatAuditValue(result.changes[0].to)).toBe("null");
|
||||
expect(formatAuditValue(result.changes[1].from)).toBe("∅");
|
||||
});
|
||||
it("shows create/delete snapshots when a legacy record has no diff", () => {
|
||||
expect(readAuditChanges(null, null, '{"name":"New"}').changes).toEqual([
|
||||
{ key: "name", from: undefined, to: "New" },
|
||||
]);
|
||||
});
|
||||
it("redacts historical sensitive nested fields before rendering", () => {
|
||||
expect(
|
||||
formatAuditValue(
|
||||
readAuditChanges('{"settings":{"from":{"password":"secret"},"to":{}}}')
|
||||
.changes[0].from,
|
||||
),
|
||||
).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -187,3 +187,24 @@ describe("getAuditLogs", () => {
|
||||
expect(result.rows[0].username).toBe("User #99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit response privacy", () => {
|
||||
it("does not serialize legacy snapshot secrets to the client", async () => {
|
||||
selectRows.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
diff: null,
|
||||
before: null,
|
||||
after: '{"name":"Alice","password":"secret-value"}',
|
||||
},
|
||||
]);
|
||||
selectCount.mockResolvedValue([{ value: 1 }]);
|
||||
selectUsers.mockResolvedValue([]);
|
||||
const result = await getAuditLogs();
|
||||
expect(JSON.stringify(result.rows)).not.toContain("secret-value");
|
||||
expect(result.rows[0].before).toBeNull();
|
||||
expect(result.rows[0].after).toBeNull();
|
||||
expect(result.rows[0].diff).toContain("Alice");
|
||||
});
|
||||
});
|
||||
+49
-21
@@ -1,5 +1,7 @@
|
||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
|
||||
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||
import { readAuditChanges } from "./audit-diff";
|
||||
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
@@ -68,23 +70,32 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
const { search, page = 1, perPage = 20 } = options;
|
||||
export async function getAuditLogs(options: AuditFilters = {}) {
|
||||
const { search, actor, action, from, until, page, perPage } =
|
||||
normalizeAuditFilters(options);
|
||||
const skip = (page - 1) * perPage;
|
||||
|
||||
const where = search
|
||||
? or(
|
||||
like(AdminAuditLog.action, `%${search}%`),
|
||||
like(AdminAuditLog.target, `%${search}%`),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const where = and(
|
||||
search
|
||||
? or(
|
||||
like(AdminAuditLog.action, `%${search}%`),
|
||||
like(AdminAuditLog.target, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
action ? eq(AdminAuditLog.action, action) : undefined,
|
||||
actor
|
||||
? /^[1-9]\d*$/.test(actor) && Number.isSafeInteger(Number(actor))
|
||||
? eq(AdminAuditLog.userId, Number(actor))
|
||||
: inArray(
|
||||
AdminAuditLog.userId,
|
||||
db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.username, actor)),
|
||||
)
|
||||
: undefined,
|
||||
from ? gte(AdminAuditLog.createdAt, from) : undefined,
|
||||
until ? lt(AdminAuditLog.createdAt, until) : undefined,
|
||||
);
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -94,6 +105,8 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
target: AdminAuditLog.target,
|
||||
targetId: AdminAuditLog.targetId,
|
||||
diff: AdminAuditLog.diff,
|
||||
before: AdminAuditLog.before,
|
||||
after: AdminAuditLog.after,
|
||||
createdAt: AdminAuditLog.createdAt,
|
||||
})
|
||||
.from(AdminAuditLog)
|
||||
@@ -116,10 +129,25 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
: [];
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
const enrichedRows = rows.map((r) => {
|
||||
const details = readAuditChanges(r.diff, r.before, r.after);
|
||||
return {
|
||||
...r,
|
||||
// Only sanitized changes may cross the server/client boundary.
|
||||
before: null,
|
||||
after: null,
|
||||
diff: details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes.length
|
||||
? JSON.stringify(
|
||||
Object.fromEntries(
|
||||
details.changes.map(({ key, from, to }) => [key, { from, to }]),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
const now = new Date("2026-09-09T12:00:00Z");
|
||||
const event = (
|
||||
startsAt: string,
|
||||
endsAt: string | null = null,
|
||||
status = "published",
|
||||
) => ({
|
||||
startsAt: new Date(startsAt),
|
||||
endsAt: endsAt ? new Date(endsAt) : null,
|
||||
status,
|
||||
});
|
||||
|
||||
describe("dashboard event timing", () => {
|
||||
it("shows a future published event without requiring an end", () => {
|
||||
expect(getDashboardEventPhase(event("2026-09-09T13:00:00Z"), now)).toBe(
|
||||
"upcoming",
|
||||
);
|
||||
});
|
||||
it("marks the start boundary and known duration as ongoing", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T12:00:00Z", "2026-09-09T13:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBe("ongoing");
|
||||
});
|
||||
it("excludes ended events including the exact end boundary", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T11:00:00Z", "2026-09-09T12:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
it("does not invent a duration for old events without an end", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(event("2026-09-08T12:00:00Z"), now),
|
||||
).toBeNull();
|
||||
});
|
||||
it.each(["draft", "cancelled", "completed"])(
|
||||
"excludes %s events",
|
||||
(status) => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T13:00:00Z", null, status),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getDashboardEventPhase(
|
||||
event: { status: string; startsAt: Date; endsAt: Date | null },
|
||||
now: Date,
|
||||
): "upcoming" | "ongoing" | null {
|
||||
if (event.status !== "published") return null;
|
||||
if (event.startsAt > now) return "upcoming";
|
||||
return event.endsAt && event.endsAt > now ? "ongoing" : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[],
|
||||
fail: false,
|
||||
query: "",
|
||||
params: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (query, params) => {
|
||||
state.query = query;
|
||||
state.params = params;
|
||||
if (state.fail) throw new Error("offline");
|
||||
return { rows: state.rows };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
|
||||
describe("dashboard event loading", () => {
|
||||
beforeEach(() => {
|
||||
state.rows = [];
|
||||
state.fail = false;
|
||||
});
|
||||
it("limits to one published ongoing or future event in date order", async () => {
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z"));
|
||||
expect(state.query).toContain("`website_events`.`status` = ?");
|
||||
expect(state.query).toContain(
|
||||
"(`website_events`.`starts_at` > ? or `website_events`.`ends_at` > ?)",
|
||||
);
|
||||
expect(state.query).toContain(
|
||||
"order by `website_events`.`starts_at` asc, `website_events`.`id` asc limit ?",
|
||||
);
|
||||
expect(state.params).toEqual([
|
||||
"published",
|
||||
"2026-09-09 12:00:00.000",
|
||||
"2026-09-09 12:00:00.000",
|
||||
1,
|
||||
]);
|
||||
});
|
||||
it("distinguishes an empty calendar from a failed query", async () => {
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: false,
|
||||
});
|
||||
state.fail = true;
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: true,
|
||||
});
|
||||
});
|
||||
it("returns the next event with the phase derived from its real dates", async () => {
|
||||
state.rows = [
|
||||
[3, "Quiz", "published", "2026-09-09 13:00:00.000", null, "Games"],
|
||||
];
|
||||
expect(
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z")),
|
||||
).toMatchObject({
|
||||
unavailable: false,
|
||||
event: { id: 3, title: "Quiz", phase: "upcoming" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { and, asc, eq, gt, or } from "drizzle-orm";
|
||||
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
export async function loadDashboardEvent(now = new Date()) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
typeName: WebsiteEventType.name,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteEvent.status, "published"),
|
||||
or(gt(WebsiteEvent.startsAt, now), gt(WebsiteEvent.endsAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(1);
|
||||
const event = rows[0];
|
||||
const phase = event ? getDashboardEventPhase(event, now) : null;
|
||||
return {
|
||||
event: event && phase ? { ...event, phase } : null,
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
return { event: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,44 @@ it("rejects unsafe job paths", async () => {
|
||||
);
|
||||
await expect(s.read("../escape")).rejects.toThrow("Invalid import ID");
|
||||
});
|
||||
|
||||
it("cancellation survives a stale worker save", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.cancelRequested).toBe(
|
||||
true,
|
||||
);
|
||||
await s.save({ ...j, state: "running" });
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
expect((await s.read(j.id)).cancelRequested).toBe(true);
|
||||
});
|
||||
it("rejects cancellation by a different operator and is idempotent for the owner", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect(await s.requestCancellation(j.id, 99)).toBeNull();
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
await Promise.all([
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
]);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
});
|
||||
it("does not alter completed jobs when cancellation arrives late", async () => {
|
||||
const s = await store(),
|
||||
j = { ...job(), state: "completed" as const };
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.state).toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
});
|
||||
it("loads bounded history for the requesting owner", async () => {
|
||||
const s = await store();
|
||||
for (let i = 0; i < 5; i++)
|
||||
await s.create({ ...job(), userId: i % 2 === 0 ? 4 : 8 });
|
||||
const result = await s.list({ userId: 4, limit: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((job) => job.userId === 4)).toBe(true);
|
||||
});
|
||||
@@ -39,20 +39,68 @@ export class ImportJobStore {
|
||||
}
|
||||
async read(id: string): Promise<ImportJob> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
return JSON.parse(
|
||||
const job: ImportJob = JSON.parse(
|
||||
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
|
||||
);
|
||||
if (await this.isCancellationRequested(id)) job.cancelRequested = true;
|
||||
return job;
|
||||
}
|
||||
async list(): Promise<ImportJob[]> {
|
||||
async isCancellationRequested(id: string): Promise<boolean> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
try {
|
||||
await fs.access(path.join(this.root, `${id}.cancel`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Cancellation has its own durable marker: a worker save cannot overwrite it. */
|
||||
async requestCancellation(
|
||||
id: string,
|
||||
userId: number,
|
||||
): Promise<ImportJob | null> {
|
||||
const job = await this.read(id).catch((error) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!job || job.userId !== userId) return null;
|
||||
if (job.state !== "queued" && job.state !== "running") return job;
|
||||
await fs
|
||||
.writeFile(path.join(this.root, `${id}.cancel`), "cancel", { flag: "wx" })
|
||||
.catch((error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
});
|
||||
return this.read(id);
|
||||
}
|
||||
async list(options?: {
|
||||
userId: number;
|
||||
limit: number;
|
||||
}): Promise<ImportJob[]> {
|
||||
const files = await fs.readdir(this.root).catch((error) => {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
});
|
||||
const jobs = await Promise.all(
|
||||
files
|
||||
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5)))
|
||||
.map((f) => this.read(f.slice(0, -5))),
|
||||
const candidates: Array<{ id: string; modified: number }> = [];
|
||||
// Metadata and payload reads are sequential, avoiding thousands of concurrent opens.
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json") || !validJobId(file.slice(0, -5))) continue;
|
||||
const stat = await fs.stat(path.join(this.root, file));
|
||||
candidates.push({ id: file.slice(0, -5), modified: stat.mtimeMs });
|
||||
}
|
||||
candidates.sort((a, b) =>
|
||||
options ? b.modified - a.modified : a.modified - b.modified,
|
||||
);
|
||||
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
const jobs: ImportJob[] = [];
|
||||
const limit = options ? Math.min(30, Math.max(1, options.limit)) : Infinity;
|
||||
for (const candidate of candidates) {
|
||||
const job = await this.read(candidate.id);
|
||||
if (options && job.userId !== options.userId) continue;
|
||||
jobs.push(job);
|
||||
if (jobs.length >= limit) break;
|
||||
}
|
||||
return options
|
||||
? jobs
|
||||
: jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ const mocks = vi.hoisted(() => ({
|
||||
set: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
list: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
import: vi.fn(),
|
||||
attachment: vi.fn(),
|
||||
export: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
updateCatalog: vi.fn(),
|
||||
updateItems: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
|
||||
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
|
||||
@@ -19,6 +22,7 @@ vi.mock("./clone-sources", () => ({ getSource: async () => null }));
|
||||
vi.mock("./furni-job-store", () => ({
|
||||
ImportJobStore: class {
|
||||
list = mocks.list;
|
||||
isCancellationRequested = mocks.cancel;
|
||||
save = mocks.save;
|
||||
},
|
||||
}));
|
||||
@@ -33,7 +37,7 @@ vi.mock("./furni-attachment", () => ({
|
||||
readFurnitureAttachment: mocks.attachment,
|
||||
}));
|
||||
vi.mock("./rcon", () => ({
|
||||
rcon: { updateCatalog: async () => {}, updateItems: async () => {} },
|
||||
rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
|
||||
}));
|
||||
|
||||
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
|
||||
@@ -44,10 +48,14 @@ let job: ImportJob;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.set.mockResolvedValue("OK");
|
||||
mocks.cancel.mockResolvedValue(false);
|
||||
mocks.eval.mockResolvedValue(1);
|
||||
mocks.export.mockImplementation((fn) => fn());
|
||||
mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] });
|
||||
mocks.save.mockResolvedValue(undefined);
|
||||
mocks.updateCatalog.mockResolvedValue(undefined);
|
||||
mocks.updateItems.mockResolvedValue(undefined);
|
||||
mocks.translate.mockResolvedValue(undefined);
|
||||
job = {
|
||||
id: "job",
|
||||
userId: 5,
|
||||
@@ -89,7 +97,7 @@ it("marks abandoned work interrupted without repeating an uncertain import", asy
|
||||
job.items[0].state = "running";
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("interrupted");
|
||||
expect(job.items[0].state).toBe("failed");
|
||||
expect(job.items[0].state).toBe("interrupted");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps errors for failed items and continues the batch", async () => {
|
||||
@@ -112,3 +120,106 @@ it("loads attachments using the job owner and exact classname", async () => {
|
||||
expect.objectContaining({ providedNitro: Buffer.from("verified") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels queued work without importing any items", async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items[0].state).toBe("cancelled");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("finishes the in-flight item and cancels only the remaining items", async () => {
|
||||
job.items.push({ ...job.items[0], classname: "table" });
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items.map((item) => item.state)).toEqual(["done", "cancelled"]);
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("reports completion if cancellation arrives during the final item", async () => {
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("completed");
|
||||
});
|
||||
it("preserves pending items when recovery finds an uncertain in-flight import", async () => {
|
||||
job.state = "running";
|
||||
job.items[0].state = "running";
|
||||
job.items.push({ ...job.items[0], classname: "table", state: "pending" });
|
||||
await drainFurnitureImports();
|
||||
expect(job.items.map((item) => item.state)).toEqual([
|
||||
"interrupted",
|
||||
"pending",
|
||||
]);
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite another worker's interruption after losing ownership during the final import", async () => {
|
||||
let persisted: ImportJob | undefined;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
persisted = structuredClone(value);
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
persisted = {
|
||||
...structuredClone(job),
|
||||
state: "interrupted",
|
||||
items: job.items.map((item) => ({ ...item, state: "interrupted" })),
|
||||
};
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||
expect(persisted?.state).toBe("interrupted");
|
||||
expect(persisted?.items[0].state).toBe("interrupted");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps an uncertain import running on disk when lease verification fails", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockRejectedValue(new Error("Redis unavailable"));
|
||||
throw new Error("SQL outcome unknown");
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
});
|
||||
it("does not persist final completion when ownership changes during cache refresh", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.updateCatalog.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots.at(-1)?.state).toBe("running");
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("done");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
expect(mocks.updateItems).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not save a translated outcome after ownership is lost during translation", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
job.translate = true;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.translate.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.translate).toHaveBeenCalledOnce();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logAudit } from "./audit";
|
||||
@@ -13,6 +14,7 @@ import { repairFurniture } from "./furniture-repair";
|
||||
import { rcon } from "./rcon";
|
||||
|
||||
const LOCK = "furniture-import-worker:v1";
|
||||
class ImportLeaseLostError extends Error {}
|
||||
let running: Promise<void> | undefined;
|
||||
export function drainFurnitureImports(): Promise<void> {
|
||||
if (running) return running;
|
||||
@@ -30,6 +32,28 @@ async function drain() {
|
||||
const token = randomUUID();
|
||||
if ((await redis.set(LOCK, token, "EX", 600, "NX")) !== "OK") return;
|
||||
let lease = true;
|
||||
async function requireLease() {
|
||||
if (!lease) throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
try {
|
||||
const owned = await redis?.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],600) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
if (owned !== 1 || !lease) {
|
||||
lease = false;
|
||||
throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
}
|
||||
} catch (error) {
|
||||
lease = false;
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
throw new ImportLeaseLostError(
|
||||
"Import worker could not verify its lease",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void redis
|
||||
?.eval(
|
||||
@@ -47,29 +71,48 @@ async function drain() {
|
||||
}, 30000);
|
||||
try {
|
||||
const store = new ImportJobStore();
|
||||
// Redis ownership checks and file writes are separate operations. These checks
|
||||
// prevent known stale writes; they do not provide atomic filesystem fencing.
|
||||
async function saveOwned(job: ImportJob) {
|
||||
await requireLease();
|
||||
await store.save(job);
|
||||
}
|
||||
for (const job of await store.list()) {
|
||||
if (!lease) break;
|
||||
await requireLease();
|
||||
if (job.state === "running") {
|
||||
job.state = "interrupted";
|
||||
for (const item of job.items)
|
||||
if (item.state === "running" || item.state === "pending") {
|
||||
item.state = "failed";
|
||||
if (item.state === "running") {
|
||||
item.state = "interrupted";
|
||||
item.error =
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.";
|
||||
"Server restarted during import. Its outcome is uncertain. Inspect local data before starting a new repair.";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
if (job.state !== "queued") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
job.state = "cancelled";
|
||||
for (const item of job.items)
|
||||
if (item.state === "pending") item.state = "cancelled";
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
job.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
await withCatalogExport(async () => {
|
||||
await ensureDirectories();
|
||||
const source = job.sourceId ? await getSource(job.sourceId) : null;
|
||||
for (const item of job.items) {
|
||||
if (!lease) throw Error("Import worker lost its lease");
|
||||
await requireLease();
|
||||
if (item.state !== "pending") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
for (const remaining of job.items)
|
||||
if (remaining.state === "pending") remaining.state = "cancelled";
|
||||
break;
|
||||
}
|
||||
item.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
try {
|
||||
if (job.sourceId && !source)
|
||||
throw Error("Import source no longer exists");
|
||||
@@ -80,6 +123,7 @@ async function drain() {
|
||||
job.userId,
|
||||
)
|
||||
: undefined;
|
||||
await requireLease();
|
||||
const result = await (job.mode === "repair"
|
||||
? repairFurniture
|
||||
: importSingleFurni)({
|
||||
@@ -90,6 +134,7 @@ async function drain() {
|
||||
nitroBaseUrl: source?.nitroBaseUrl,
|
||||
iconBaseUrl: source?.iconBaseUrl,
|
||||
});
|
||||
await requireLease();
|
||||
item.warnings = result.warnings;
|
||||
item.itemId = result.itemId;
|
||||
if (!result.ok) throw Error(result.error || "Import failed");
|
||||
@@ -114,16 +159,21 @@ async function drain() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
await requireLease();
|
||||
item.state = "failed";
|
||||
item.error =
|
||||
error instanceof Error ? error.message : "Import failed";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await requireLease();
|
||||
await rcon.updateItems();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
for (const item of job.items)
|
||||
if (item.state === "done") {
|
||||
item.warnings ??= [];
|
||||
@@ -131,17 +181,25 @@ async function drain() {
|
||||
}
|
||||
}
|
||||
});
|
||||
job.state = "completed";
|
||||
await store.save(job);
|
||||
await requireLease();
|
||||
job.state = job.items.some((item) => item.state === "cancelled")
|
||||
? "cancelled"
|
||||
: "completed";
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
await runCatalogExport();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await redis.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
await redis
|
||||
.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
)
|
||||
.catch((error) => {
|
||||
logServerError("furni.worker_release_failed", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
import { siteSettings } from "./site-settings";
|
||||
export async function loadUserDashboard(userId: number) {
|
||||
const [
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
@@ -29,6 +31,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
friends,
|
||||
currencyRows,
|
||||
] = await Promise.all([
|
||||
loadDashboardEvent(),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -62,7 +65,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => []),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
@@ -85,7 +88,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
@@ -144,6 +147,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
];
|
||||
|
||||
return {
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { verifyRuntimeMetadata } from "../../scripts/verify-runtime-metadata.mjs";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("verifies runtime names and domains without requesting DB-dependent login HTML", async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/login"))
|
||||
return new Response("Database unavailable", { status: 500 });
|
||||
return url.endsWith("webmanifest")
|
||||
? Response.json({ name: "Fixture alpha" })
|
||||
: new Response("https://alpha.test/sitemap.xml");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetcher.mock.calls.map((c) => c[0])).not.toContain(
|
||||
"https://alpha.test/login",
|
||||
);
|
||||
});
|
||||
it.each(["manifest", "domain", "http"])("rejects wrong %s", async (mode) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) =>
|
||||
url.endsWith("webmanifest")
|
||||
? Response.json(
|
||||
{ name: mode === "manifest" ? "Build fixture" : "Fixture alpha" },
|
||||
{ status: mode === "http" ? 500 : 200 },
|
||||
)
|
||||
: new Response("https://wrong.test"),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -7,7 +7,138 @@
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -23,5 +154,36 @@
|
||||
"fullscreenExit": "الخروج من ملء الشاشة",
|
||||
"show": "إظهار الشريط",
|
||||
"emulatorUnknown": "حالة المحاكي غير معروفة"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Запазва се...",
|
||||
"deleteArticle": "Изтриване на статия",
|
||||
"deleteConfirm": "Това действие не може да бъде отменено. Статията ще бъде окончателно премахната.",
|
||||
"cancel": "Отказ"
|
||||
}
|
||||
"cancel": "Отказ",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Етикети",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Изход от цял екран",
|
||||
"show": "Покажи лентата",
|
||||
"emulatorUnknown": "Състоянието на емулатора е неизвестно"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Ukládání…",
|
||||
"deleteArticle": "Smazat článek",
|
||||
"deleteConfirm": "Tuto akci nelze vrátit zpět. Článek bude trvale odstraněn.",
|
||||
"cancel": "Zrušit"
|
||||
}
|
||||
"cancel": "Zrušit",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tagy",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Ukončit celou obrazovku",
|
||||
"show": "Zobrazit lištu",
|
||||
"emulatorUnknown": "Stav emulátoru neznámý"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Gemmer...",
|
||||
"deleteArticle": "Slet artikel",
|
||||
"deleteConfirm": "Denne handling kan ikke fortrydes. Artiklen fjernes permanent.",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
"cancel": "Annuller",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Afslut fuldskærm",
|
||||
"show": "Vis værktøjslinje",
|
||||
"emulatorUnknown": "Emulatorstatus ukendt"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -938,8 +938,42 @@
|
||||
"saving": "Wird gespeichert…",
|
||||
"deleteArticle": "Artikel löschen",
|
||||
"deleteConfirm": "Dieser Vorgang kann nicht rückgängig gemacht werden. Der Artikel wird dauerhaft entfernt.",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
"cancel": "Abbrechen",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
@@ -2042,7 +2076,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2944,6 +2990,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Vollbild beenden",
|
||||
"show": "Leiste anzeigen",
|
||||
"emulatorUnknown": "Emulatorstatus unbekannt"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Αποθήκευση…",
|
||||
"deleteArticle": "Διαγραφή άρθρου",
|
||||
"deleteConfirm": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Το άρθρο θα καταργηθεί οριστικά.",
|
||||
"cancel": "Ακύρωση"
|
||||
}
|
||||
"cancel": "Ακύρωση",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Ετικέτες",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Έξοδος από πλήρη οθόνη",
|
||||
"show": "Εμφάνιση γραμμής",
|
||||
"emulatorUnknown": "Άγνωστη κατάσταση εξομοιωτή"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
-6
@@ -1175,12 +1175,46 @@
|
||||
"imagePreview": "Article image preview",
|
||||
"previewTitle": "Article preview",
|
||||
"previewDescription": "Content preview. The published page applies the site theme.",
|
||||
"previewFrame": "Article content preview"
|
||||
"previewFrame": "Article content preview",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"articlesShown": "Articles shown",
|
||||
"latest": "Latest article",
|
||||
"mostRecentPost": "Most recent publication",
|
||||
"recentArticles": "Recent articles"
|
||||
"recentArticles": "Recent articles",
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"events": {
|
||||
"newEvent": "+ New event",
|
||||
@@ -2985,7 +3019,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -3758,7 +3804,19 @@
|
||||
"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"
|
||||
"allStatuses": "All statuses",
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"importHistory": {
|
||||
"summary": "Import history · {count} active",
|
||||
@@ -3773,7 +3831,56 @@
|
||||
"failed": "Failed",
|
||||
"pending": "Pending",
|
||||
"retryFailed": "Retry failed items only",
|
||||
"retryUnfinished": "Retry unfinished items"
|
||||
"retryUnfinished": "Retry unfinished items",
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
@@ -4028,7 +4135,17 @@
|
||||
"bad_config": "The reward is not configured correctly. Contact the staff.",
|
||||
"ratelimit": "Too many attempts. Wait a moment and try again.",
|
||||
"error": "The reward could not be claimed. Please try again."
|
||||
}
|
||||
},
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -4044,5 +4161,36 @@
|
||||
"fullscreenExit": "Exit fullscreen",
|
||||
"show": "Show toolbar",
|
||||
"emulatorUnknown": "Emulator status unknown"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -938,8 +938,42 @@
|
||||
"saving": "Guardando…",
|
||||
"deleteArticle": "Eliminar artículo",
|
||||
"deleteConfirm": "Esta acción no se puede deshacer. El artículo se eliminará permanentemente.",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
"cancel": "Cancelar",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiquetas",
|
||||
@@ -2042,7 +2076,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2944,6 +2990,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Salir de pantalla completa",
|
||||
"show": "Mostrar barra",
|
||||
"emulatorUnknown": "Estado del emulador desconocido"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,138 @@
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -23,5 +154,36 @@
|
||||
"fullscreenExit": "Lopeta koko näyttö",
|
||||
"show": "Näytä työkalupalkki",
|
||||
"emulatorUnknown": "Emulaattorin tila tuntematon"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -938,8 +938,42 @@
|
||||
"saving": "Enregistrement…",
|
||||
"deleteArticle": "Supprimer l'article",
|
||||
"deleteConfirm": "Cette action est irréversible. L'article sera définitivement supprimé.",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
"cancel": "Annuler",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Étiquettes",
|
||||
@@ -2042,7 +2076,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2944,6 +2990,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Quitter le plein écran",
|
||||
"show": "Afficher la barre",
|
||||
"emulatorUnknown": "État de l'émulateur inconnu"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Spremanje...",
|
||||
"deleteArticle": "Izbriši članak",
|
||||
"deleteConfirm": "Ova se radnja ne može poništiti. Članak će biti trajno uklonjen.",
|
||||
"cancel": "Odustani"
|
||||
}
|
||||
"cancel": "Odustani",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "oznake",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Izađi iz cijelog zaslona",
|
||||
"show": "Prikaži traku",
|
||||
"emulatorUnknown": "Status emulatora nepoznat"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Mentés…",
|
||||
"deleteArticle": "Cikk törlése",
|
||||
"deleteConfirm": "Ez a művelet nem vonható vissza. A cikket véglegesen eltávolítjuk.",
|
||||
"cancel": "Mégse"
|
||||
}
|
||||
"cancel": "Mégse",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Címkék",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Kilépés a teljes képernyőből",
|
||||
"show": "Sáv megjelenítése",
|
||||
"emulatorUnknown": "Az emulátor állapota ismeretlen"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
-6
@@ -1149,12 +1149,46 @@
|
||||
"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"
|
||||
"previewFrame": "Anteprima contenuto della news",
|
||||
"autosaveLoading": "Caricamento delle bozze salvate…",
|
||||
"autosaveReady": "Salvataggio automatico della bozza privata attivo.",
|
||||
"autosaveSaving": "Salvataggio della bozza privata…",
|
||||
"autosaveSaved": "Bozza privata salvata. Per applicare le modifiche alla pubblicazione premi Salva.",
|
||||
"autosaveFailed": "Recupero della bozza non disponibile. Mantieni aperta la pagina e usa Salva; ricarica per riprovare il recupero.",
|
||||
"autosaveConflict": "Un’altra scheda ha modificato questa bozza. Salvataggio automatico sospeso: ricarica per esaminare la versione più recente.",
|
||||
"recoveryFound": "È disponibile una bozza privata non completata. Recuperala o eliminala per attivare il salvataggio automatico.",
|
||||
"recoverDraft": "Recupera bozza",
|
||||
"discardDraft": "Elimina bozza salvata",
|
||||
"confirmRecovery": "Sostituire il contenuto nell’editor con la bozza recuperata? Le modifiche non saranno pubblicate finché non salvi.",
|
||||
"confirmDiscardDraft": "Eliminare definitivamente la bozza di recupero salvata? Il contenuto corrente dell’editor resterà disponibile.",
|
||||
"revisionHistory": "Versioni salvate precedenti (ultime 20)",
|
||||
"restoreRevision": "Ripristina nell’editor",
|
||||
"confirmRevision": "Sostituire il contenuto corrente con questa versione precedente come bozza? Controllala e scegli lo stato di pubblicazione prima di salvare.",
|
||||
"editConflict": "Questo articolo è stato modificato dopo la tua apertura. Le tue modifiche non sono state applicate. Copia il lavoro, ricarica l’articolo e integra le modifiche prima di salvare di nuovo.",
|
||||
"retryRecovery": "Ricarica il recupero senza modificare il modulo"
|
||||
},
|
||||
"articlesShown": "Articoli mostrati",
|
||||
"latest": "Ultimo articolo",
|
||||
"mostRecentPost": "Pubblicazione più recente",
|
||||
"recentArticles": "Articoli recenti"
|
||||
"recentArticles": "Articoli recenti",
|
||||
"listActionError": "Non è stato possibile completare l’operazione sulla news.",
|
||||
"listStatus": "Stato di pubblicazione",
|
||||
"listStatus_all": "Tutte le news",
|
||||
"listStatus_draft": "Bozza",
|
||||
"listStatus_published": "Pubblicata",
|
||||
"listStatus_scheduled": "Programmata",
|
||||
"listSearch": "Cerca nel titolo o nel sommario",
|
||||
"listApply": "Applica",
|
||||
"listLoading": "Caricamento…",
|
||||
"listReset": "Reimposta filtri",
|
||||
"listUnavailable": "Le news non sono al momento disponibili. Riprova.",
|
||||
"listRetry": "Riprova",
|
||||
"listTotal": "{count, plural, one {# news trovata} other {# news trovate}}",
|
||||
"listNoMatches": "Nessuna news corrisponde ai filtri. Modifica la ricerca o reimposta i filtri.",
|
||||
"listPagination": "Pagine delle news",
|
||||
"listPrevious": "Precedente",
|
||||
"listNext": "Successiva",
|
||||
"listPage": "Pagina {page} di {total}"
|
||||
},
|
||||
"events": {
|
||||
"newEvent": "+ Nuovo evento",
|
||||
@@ -2904,7 +2938,19 @@
|
||||
"colDate": "Data",
|
||||
"searchPlaceholder": "Cerca per azione o destinazione…",
|
||||
"changesCount": "{count} modifiche",
|
||||
"notAvailable": "N/D"
|
||||
"notAvailable": "N/D",
|
||||
"filtersTitle": "Filtra il registro attività",
|
||||
"filterActor": "Autore (nome esatto o ID)",
|
||||
"filterAction": "Azione esatta",
|
||||
"filterFrom": "Dal giorno (UTC)",
|
||||
"filterTo": "Fino al giorno (UTC)",
|
||||
"filtersHint": "Le date includono l’intera giornata in UTC. Azioni e nomi utente devono corrispondere esattamente. Le attività più recenti compaiono per prime.",
|
||||
"applyFilters": "Applica filtri",
|
||||
"resetFilters": "Reimposta filtri",
|
||||
"invalidDetails": "I dettagli di questa vecchia registrazione non sono visualizzabili.",
|
||||
"beforeValue": "Prima",
|
||||
"afterValue": "Dopo",
|
||||
"exportHint": "Il CSV esporta la pagina filtrata corrente, fino a 100 registrazioni."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Messaggio",
|
||||
@@ -3733,7 +3779,19 @@
|
||||
"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"
|
||||
"allStatuses": "Tutti gli stati",
|
||||
"firstSeen": "Prima occorrenza nei dati conservati",
|
||||
"lastSeen": "Ultima occorrenza",
|
||||
"affectedUsers": "Utenti identificati distinti",
|
||||
"unidentifiedEvents": "Eventi senza ID utente verificato",
|
||||
"metricsScope": "I conteggi riguardano solo i dati conservati caricati qui. Gli utenti sono conteggiati solo tramite ID numerici del server; le segnalazioni anonime e del browser non vengono attribuite.",
|
||||
"assignedStaff": "Assegnato allo staff #{id}",
|
||||
"unassigned": "Non assegnato",
|
||||
"assignSelf": "Assegna a me",
|
||||
"clearAssignment": "Rimuovi assegnazione",
|
||||
"openOperation": "Apri area collegata",
|
||||
"releaseOccurrences": "Occorrenze per release",
|
||||
"releaseScope": "Queste release compaiono nei dati conservati. La prima occorrenza disponibile non dimostra quale release abbia introdotto l’errore."
|
||||
},
|
||||
"importHistory": {
|
||||
"summary": "Storico importazioni · {count} attive",
|
||||
@@ -3748,7 +3806,56 @@
|
||||
"failed": "Non riuscito",
|
||||
"pending": "In attesa",
|
||||
"retryFailed": "Riprova solo i furni falliti",
|
||||
"retryUnfinished": "Riprendi i furni non completati"
|
||||
"retryUnfinished": "Riprendi i furni non completati",
|
||||
"cancelled": "Annullato",
|
||||
"cancelFailed": "Impossibile richiedere l’annullamento. Riprova o aggiorna la cronologia.",
|
||||
"cancelRequested": "Annullamento richiesto. L’elemento corrente verrà completato; quelli rimanenti non verranno avviati.",
|
||||
"cancelHint": "L’annullamento conserva gli elementi completati e lascia terminare quello corrente.",
|
||||
"cancelRemaining": "Annulla elementi rimanenti",
|
||||
"historyLimit": "Sono mostrati fino a 30 processi aggiornati più di recente.",
|
||||
"refreshHistory": "Aggiorna cronologia",
|
||||
"showMoreItems": "Mostra altri 50 elementi",
|
||||
"uncertainOutcome": "Un elemento interrotto potrebbe avere già modificato i dati locali. È escluso dai tentativi successivi.",
|
||||
"reviewLocalData": "Controlla i dati locali"
|
||||
},
|
||||
"installation": {
|
||||
"title": "Diagnostica installazione",
|
||||
"hint": "Controlli in sola lettura di questa installazione. Non verificato indica evidenza insufficiente; una configurazione presente non prova la raggiungibilità esterna.",
|
||||
"checked": "Controllato alle {time} · verifiche completate in {ms} ms",
|
||||
"recheck": "Ricontrolla",
|
||||
"ok": "Controllo superato",
|
||||
"failed": "Da verificare",
|
||||
"unknown": "Non verificato",
|
||||
"missing": "Non configurato",
|
||||
"release": "Versione in esecuzione",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulatore",
|
||||
"storage": "Archiviazione scrivibile",
|
||||
"migrations": "Migrazioni database",
|
||||
"worker": "Worker in background",
|
||||
"renderer": "Configurazione renderer",
|
||||
"registry": "Registro delle immagini",
|
||||
"releaseHint": "Identificativo completo della build in esecuzione. Permette di confrontare installazioni e scegliere una versione precedente per il ripristino.",
|
||||
"databaseHint": "Connessione e tempo misurato della query SELECT 1.",
|
||||
"redisHint": "Opzionale per alcune pagine; necessario per le importazioni in coda.",
|
||||
"emulatorHint": "Risposta al ping dell’emulatore configurato.",
|
||||
"storageHint": "Permessi di lettura e scrittura nelle cartelle storage, risorse Nitro e SWF.",
|
||||
"migrationsHint": "Numero di migrazioni incluse nella versione e assenti dalla cronologia del database. Applicale attraverso il processo di rilascio.",
|
||||
"workerHint": "Ultimo segnale del worker in Redis. Oltre due minuti richiede verifica; nessun segnale non permette di stabilire se il worker sia attivo.",
|
||||
"rendererHint": "Il client usa l’indirizzo configurato oppure /nitro-client/ come percorso predefinito. Apri separatamente il gioco per controllare rendering e risorse.",
|
||||
"registryHint": "Le credenziali del registro sono nella CI, non nel CMS. Controlla il lavoro di pubblicazione e il tag immutabile della versione in Gitea."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Da controllare",
|
||||
"hint": "Attività accessibili: importazioni negli ultimi 30 lavori personali; errori in un massimo di 1.000 eventi conservati. Attività precedenti possono restare fuori da questi conteggi.",
|
||||
"empty": "Nessuna attività in sospeso trovata nei dati controllati.",
|
||||
"errors": "Gruppi di errori CMS aperti",
|
||||
"imports": "Le tue importazioni da verificare",
|
||||
"tickets": "Ticket di assistenza aperti",
|
||||
"publications": "Bozze delle news",
|
||||
"unavailable": "{source}: dati temporaneamente non disponibili.",
|
||||
"installation": "Diagnostica installazione"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
@@ -4033,7 +4140,17 @@
|
||||
"bad_config": "Il premio non è configurato correttamente. Contatta lo staff.",
|
||||
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
|
||||
"error": "Non è stato possibile riscattare il premio. Riprova."
|
||||
}
|
||||
},
|
||||
"messagesCaughtUp": "Nessun messaggio da leggere",
|
||||
"messagesUnavailable": "Apri la posta per controllare",
|
||||
"eventTitle": "In programma",
|
||||
"allEvents": "Tutti gli eventi",
|
||||
"eventsUnavailable": "Il calendario non è al momento disponibile. Apri tutti gli eventi per riprovare.",
|
||||
"eventOngoing": "In corso",
|
||||
"eventUpcoming": "Prossimo evento",
|
||||
"eventDetails": "Scopri l’evento",
|
||||
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
|
||||
"roomsUnavailable": "Le stanze non sono al momento disponibili. Apri la community per riprovare."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -4049,5 +4166,36 @@
|
||||
"fullscreenExit": "Esci dalla schermo intero",
|
||||
"show": "Mostra barra",
|
||||
"emulatorUnknown": "Stato dell'emulatore sconosciuto"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completezza e prossimi passi",
|
||||
"parts": {
|
||||
"sql": "Elemento SQL",
|
||||
"catalog": "Offerta catalogo",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Icona locale",
|
||||
"nitro": "File Nitro"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Presente",
|
||||
"missing": "Mancante",
|
||||
"unknown": "Non verificato",
|
||||
"conflict": "Da controllare"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Importa o ripara questo furni per creare il record mancante.",
|
||||
"catalog": "Importa o ripara questo furni per aggiungere un’offerta con la categoria e il prezzo automatici.",
|
||||
"furnidata": "Importa o ripara questo furni per aggiungere i dati mancanti.",
|
||||
"icon": "Importa o ripara questo furni per recuperare l’icona locale mancante.",
|
||||
"nitro": "Importa o ripara questo furni per recuperare il file. Per un elemento esistente puoi anche usare Rigenera .nitro.",
|
||||
"recheck": "Ricontrolla lo stato locale prima della riparazione. Se il problema persiste, verifica l’accesso ai file locali.",
|
||||
"audit": "Controlla i duplicati e la corrispondenza degli ID sprite e dei tipi in Audit catalogo prima della riparazione."
|
||||
},
|
||||
"openAudit": "Apri Audit catalogo per risolvere i conflitti",
|
||||
"excludedConflicts": "{count, plural, one {# elemento richiede un controllo ed è escluso da questa importazione.} other {# elementi richiedono un controllo e sono esclusi da questa importazione.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,138 @@
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -23,5 +154,36 @@
|
||||
"fullscreenExit": "全画面を終了",
|
||||
"show": "ツールバーを表示",
|
||||
"emulatorUnknown": "エミュレータの状態が不明です"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -1137,8 +1137,42 @@
|
||||
"imagePreview": "Voorbeeld van artikelafbeelding",
|
||||
"previewTitle": "Artikelvoorbeeld",
|
||||
"previewDescription": "Voorbeeld van de inhoud. De gepubliceerde pagina gebruikt het sitethema.",
|
||||
"previewFrame": "Voorbeeld van artikelinhoud"
|
||||
}
|
||||
"previewFrame": "Voorbeeld van artikelinhoud",
|
||||
"autosaveLoading": "Opgeslagen concepten laden…",
|
||||
"autosaveReady": "Automatisch opslaan van je privéconcept is actief.",
|
||||
"autosaveSaving": "Privéconcept opslaan…",
|
||||
"autosaveSaved": "Privéconcept opgeslagen. Klik op Opslaan om publicatiewijzigingen toe te passen.",
|
||||
"autosaveFailed": "Conceptherstel is niet beschikbaar. Houd deze pagina open en gebruik Opslaan; herlaad de pagina om herstel opnieuw te proberen.",
|
||||
"autosaveConflict": "Een ander tabblad heeft dit concept gewijzigd. Automatisch opslaan is gepauzeerd: herlaad de pagina om het nieuwere concept te bekijken voordat je verdergaat.",
|
||||
"recoveryFound": "Er is een onvoltooid privéconcept beschikbaar. Herstel of verwijder het om automatisch opslaan te activeren.",
|
||||
"recoverDraft": "Concept herstellen",
|
||||
"discardDraft": "Opgeslagen concept verwijderen",
|
||||
"confirmRecovery": "De huidige inhoud van de editor vervangen door dit herstelde concept? De wijzigingen worden pas gepubliceerd als je opslaat.",
|
||||
"confirmDiscardDraft": "Je opgeslagen herstelconcept definitief verwijderen? De huidige inhoud van de editor blijft behouden.",
|
||||
"revisionHistory": "Vorige opgeslagen versies (laatste 20)",
|
||||
"restoreRevision": "Herstellen in editor",
|
||||
"confirmRevision": "De huidige inhoud vervangen door deze vorige versie als concept? Controleer de inhoud en kies een publicatiestatus voordat je opslaat.",
|
||||
"editConflict": "Dit artikel is gewijzigd sinds je het hebt geopend. Je wijzigingen zijn niet toegepast. Kopieer je werk, herlaad het artikel en voeg de wijzigingen samen voordat je opnieuw opslaat.",
|
||||
"retryRecovery": "Herstelgegevens opnieuw laden zonder dit formulier te wijzigen"
|
||||
},
|
||||
"listActionError": "De actie voor dit artikel kon niet worden voltooid.",
|
||||
"listStatus": "Publicatiestatus",
|
||||
"listStatus_all": "Alle artikelen",
|
||||
"listStatus_draft": "Concept",
|
||||
"listStatus_published": "Gepubliceerd",
|
||||
"listStatus_scheduled": "Gepland",
|
||||
"listSearch": "Zoeken in titel of samenvatting",
|
||||
"listApply": "Toepassen",
|
||||
"listLoading": "Laden…",
|
||||
"listReset": "Filters wissen",
|
||||
"listUnavailable": "Artikelen zijn tijdelijk niet beschikbaar. Probeer het opnieuw.",
|
||||
"listRetry": "Opnieuw proberen",
|
||||
"listTotal": "{count, plural, one {# gevonden artikel} other {# gevonden artikelen}}",
|
||||
"listNoMatches": "Geen artikelen gevonden met deze filters. Pas de zoekopdracht aan of wis de filters.",
|
||||
"listPagination": "Artikelpagina’s",
|
||||
"listPrevious": "Vorige",
|
||||
"listNext": "Volgende",
|
||||
"listPage": "Pagina {page} van {total}"
|
||||
},
|
||||
"events": {
|
||||
"newEvent": "+ Nieuw evenement",
|
||||
@@ -2523,7 +2557,19 @@
|
||||
"colDate": "Datum",
|
||||
"searchPlaceholder": "Zoek op actie of doel…",
|
||||
"changesCount": "{count} wijzigingen",
|
||||
"notAvailable": "n.v.t."
|
||||
"notAvailable": "n.v.t.",
|
||||
"filtersTitle": "Activiteitenlogboek filteren",
|
||||
"filterActor": "Uitvoerder (exacte gebruikersnaam of ID)",
|
||||
"filterAction": "Exacte actie",
|
||||
"filterFrom": "Vanaf datum (UTC)",
|
||||
"filterTo": "Tot en met datum (UTC)",
|
||||
"filtersHint": "Datums omvatten de volledige dag in UTC. Acties en gebruikersnamen moeten exact overeenkomen. De nieuwste activiteiten staan bovenaan.",
|
||||
"applyFilters": "Filters toepassen",
|
||||
"resetFilters": "Filters wissen",
|
||||
"invalidDetails": "Deze oude gegevens kunnen niet worden weergegeven.",
|
||||
"beforeValue": "Voor",
|
||||
"afterValue": "Na",
|
||||
"exportHint": "Het CSV-bestand bevat de huidige gefilterde pagina, met maximaal 100 vermeldingen."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Bericht",
|
||||
@@ -3319,6 +3365,71 @@
|
||||
"hideItem": "Item verbergen",
|
||||
"showItem": "Item tonen",
|
||||
"pinned": "Vastgezet"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "Eerste gebeurtenis in bewaarde gegevens",
|
||||
"lastSeen": "Laatste gebeurtenis",
|
||||
"affectedUsers": "Unieke geïdentificeerde gebruikers",
|
||||
"unidentifiedEvents": "Gebeurtenissen zonder geverifieerde gebruikers-ID",
|
||||
"metricsScope": "De tellingen gelden alleen voor de bewaarde gegevens die hier zijn geladen. Gebruikers worden uitsluitend geteld op basis van numerieke gebruikers-ID’s van de server; anonieme meldingen en browsermeldingen worden niet aan gebruikers gekoppeld.",
|
||||
"assignedStaff": "Toegewezen aan medewerker #{id}",
|
||||
"unassigned": "Niet toegewezen",
|
||||
"assignSelf": "Aan mij toewijzen",
|
||||
"clearAssignment": "Toewijzing verwijderen",
|
||||
"openOperation": "Gerelateerd onderdeel openen",
|
||||
"releaseOccurrences": "Gebeurtenissen per release",
|
||||
"releaseScope": "Deze releases komen voor in de bewaarde gegevens. De eerste beschikbare gebeurtenis bewijst niet welke release de fout heeft geïntroduceerd."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installatiediagnose",
|
||||
"hint": "Alleen-lezen controles van deze installatie. Niet geverifieerd betekent onvoldoende bewijs; een instelling bewijst geen externe bereikbaarheid.",
|
||||
"checked": "Gecontroleerd om {time} · controles voltooid in {ms} ms",
|
||||
"recheck": "Opnieuw controleren",
|
||||
"ok": "Controle geslaagd",
|
||||
"failed": "Controle nodig",
|
||||
"unknown": "Niet geverifieerd",
|
||||
"missing": "Niet ingesteld",
|
||||
"release": "Actieve versie",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Schrijfbare opslag",
|
||||
"migrations": "Databasemigraties",
|
||||
"worker": "Achtergrondworker",
|
||||
"renderer": "Rendererinstellingen",
|
||||
"registry": "Containerregister",
|
||||
"releaseHint": "De volledige identificatie van de actieve build. Vergelijk installaties en kies een vorige image voor herstel.",
|
||||
"databaseHint": "Verbindingscontrole en gemeten responstijd van SELECT 1.",
|
||||
"redisHint": "Optioneel voor sommige pagina’s; vereist voor imports in de wachtrij.",
|
||||
"emulatorHint": "Antwoord op de ingestelde emulatorping.",
|
||||
"storageHint": "Lees- en schrijfrechten voor opslag, Nitro-assets en SWF-mappen.",
|
||||
"migrationsHint": "Aantal meegeleverde migraties dat ontbreekt in de databasehistorie. Voer migraties uit via het releaseproces.",
|
||||
"workerHint": "Laatste workersignaal in Redis. Ouder dan twee minuten vereist onderzoek; zonder signaal is niet vast te stellen of de worker actief is.",
|
||||
"rendererHint": "De client gebruikt het ingestelde adres of standaard /nitro-client/. Open het spel apart om de weergave en assets te controleren.",
|
||||
"registryHint": "Registergegevens staan in CI, niet in het CMS. Controleer de publicatietaak en onveranderlijke versietag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Geannuleerd",
|
||||
"cancelFailed": "Annuleren kon niet worden aangevraagd. Probeer het opnieuw of vernieuw de geschiedenis.",
|
||||
"cancelRequested": "Annulering aangevraagd. Het huidige item wordt afgerond; de resterende items worden niet gestart.",
|
||||
"cancelHint": "Annuleren behoudt voltooide items en laat het huidige item afronden.",
|
||||
"cancelRemaining": "Resterende items annuleren",
|
||||
"historyLimit": "De 30 meest recent bijgewerkte taken worden getoond.",
|
||||
"refreshHistory": "Geschiedenis vernieuwen",
|
||||
"showMoreItems": "Nog 50 items tonen",
|
||||
"uncertainOutcome": "Een onderbroken item heeft mogelijk al lokale gegevens gewijzigd. Het wordt niet opnieuw geprobeerd.",
|
||||
"reviewLocalData": "Lokale gegevens controleren"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Aandacht nodig",
|
||||
"hint": "Toegankelijke taken: imports uit je laatste 30 taken; fouten uit maximaal 1.000 bewaarde meldingen. Ouder werk kan buiten deze tellingen vallen.",
|
||||
"empty": "Geen openstaande taken gevonden in de gecontroleerde gegevens.",
|
||||
"errors": "Open CMS-foutgroepen",
|
||||
"imports": "Je imports die controle nodig hebben",
|
||||
"tickets": "Open ondersteuningstickets",
|
||||
"publications": "Nieuwsconcepten",
|
||||
"unavailable": "{source}: gegevens tijdelijk niet beschikbaar.",
|
||||
"installation": "Installatiediagnose"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
@@ -3457,6 +3568,18 @@
|
||||
"failed": "Mislukt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "Geen ongelezen berichten",
|
||||
"roomsUnavailable": "Kamers zijn tijdelijk niet beschikbaar. Open de community om het opnieuw te proberen.",
|
||||
"eventDetails": "Evenement bekijken",
|
||||
"allEvents": "Alle evenementen",
|
||||
"eventTitle": "Op de agenda",
|
||||
"noUpcomingEvents": "Er zijn nog geen nieuwe evenementen aangekondigd.",
|
||||
"eventUpcoming": "Binnenkort",
|
||||
"eventsUnavailable": "De agenda is tijdelijk niet beschikbaar. Open alle evenementen om het opnieuw te proberen.",
|
||||
"messagesUnavailable": "Open je inbox om te kijken",
|
||||
"eventOngoing": "Nu bezig"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -3472,5 +3595,36 @@
|
||||
"fullscreenExit": "Volledig scherm afsluiten",
|
||||
"show": "Werkbalk tonen",
|
||||
"emulatorUnknown": "Emulatorstatus onbekend"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Volledigheid en vervolgstappen",
|
||||
"parts": {
|
||||
"sql": "SQL-item",
|
||||
"catalog": "Catalogusaanbod",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Lokaal pictogram",
|
||||
"nitro": "Nitro-bestand"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Aanwezig",
|
||||
"missing": "Ontbreekt",
|
||||
"unknown": "Niet gecontroleerd",
|
||||
"conflict": "Controle nodig"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Importeer of herstel dit meubel om het ontbrekende itemrecord aan te maken.",
|
||||
"catalog": "Importeer of herstel dit meubel om een aanbod met de automatische categorie en prijs toe te voegen.",
|
||||
"furnidata": "Importeer of herstel dit meubel om de ontbrekende meubelgegevens toe te voegen.",
|
||||
"icon": "Importeer of herstel dit meubel om het ontbrekende lokale pictogram op te halen.",
|
||||
"nitro": "Importeer of herstel dit meubel om het bestand op te halen. Bij een bestaand item kun je ook .nitro opnieuw genereren.",
|
||||
"recheck": "Controleer de lokale status opnieuw voordat je herstelt. Controleer de toegang tot lokale bestanden als het probleem aanhoudt.",
|
||||
"audit": "Controleer dubbele records en overeenkomende sprite-ID’s en typen in Cataloguscontrole voordat je herstelt."
|
||||
},
|
||||
"openAudit": "Open Cataloguscontrole om conflicten op te lossen",
|
||||
"excludedConflicts": "{count, plural, one {# item moet worden gecontroleerd en is uitgesloten van deze import.} other {# items moeten worden gecontroleerd en zijn uitgesloten van deze import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Lagrer …",
|
||||
"deleteArticle": "Slett artikkelen",
|
||||
"deleteConfirm": "Denne handlingen kan ikke angres. Artikkelen vil bli fjernet permanent.",
|
||||
"cancel": "Avbryt"
|
||||
}
|
||||
"cancel": "Avbryt",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tagger",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Avslutt fullskjerm",
|
||||
"show": "Vis verktøylinje",
|
||||
"emulatorUnknown": "Emulatorstatus ukjent"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -968,8 +968,42 @@
|
||||
"saving": "Zapisuję…",
|
||||
"deleteArticle": "Usuń artykuł",
|
||||
"deleteConfirm": "Tej akcji nie można cofnąć. Artykuł zostanie trwale usunięty.",
|
||||
"cancel": "Anuluj"
|
||||
}
|
||||
"cancel": "Anuluj",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tagi",
|
||||
@@ -2072,7 +2106,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2974,6 +3020,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Zamknij pełny ekran",
|
||||
"show": "Pokaż pasek",
|
||||
"emulatorUnknown": "Status emulatora nieznany"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -968,8 +968,42 @@
|
||||
"saving": "Salvando…",
|
||||
"deleteArticle": "Excluir artigo",
|
||||
"deleteConfirm": "Esta ação não pode ser desfeita. O artigo será removido permanentemente.",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
"cancel": "Cancelar",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiquetas",
|
||||
@@ -2072,7 +2106,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2974,6 +3020,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Sair da tela cheia",
|
||||
"show": "Mostrar barra",
|
||||
"emulatorUnknown": "Estado do emulador desconhecido"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user