feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+18
View File
@@ -58,6 +58,24 @@ jobs:
time pnpm test:coverage --maxWorkers=4
fi
# One-time Linux capture, replaced by comparison after artifact review.
- name: Install UI test browser
run: pnpm exec playwright install chromium
- name: Capture Linux UI references
run: pnpm test:ui:update
- name: Upload UI results
if: always()
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
with:
name: ui-results
path: |
e2e/ui/__screenshots__/linux/
playwright-report/ui/
test-results/ui/
retention-days: 14
# ─────────────────────────────────────────────
# Docker build & deploy
# Draait op de host (self-hosted) zodat Docker
+10
View File
@@ -0,0 +1,10 @@
# Isolated UI regression checks
Run `pnpm test:ui` after `pnpm install --frozen-lockfile` and `pnpm exec playwright install chromium`.
The fixture server bundles real CMS components with local HTTP/server-action fixtures. It does not use the database or production credentials. Production smoke tests remain in the separate `pnpm test:e2e` command.
The suite covers accessible settings switches, news editor/preview, support-table filters, import history, attachment recovery and news/event recovery. Axe checks WCAG A/AA rules. The sandboxed preview cannot execute Axe; its accessible name and keyboard exit are checked separately. Automated checks do not replace a full accessibility audit.
Visual references are committed under `__screenshots__/<platform>/<viewport>`. Normal runs compare references and must not update them. For an intentional UI change, use `pnpm test:ui:update`, inspect every changed PNG and commit only accepted references. Keep the Playwright browser version aligned with the lockfile. Linux references must be captured on the Linux runner; Windows references are not interchangeable.
Fixtures contain synthetic data only. Do not add production requests, environment secrets, ignored local documents or database access to tests. CI retains test reports and failures as the `ui-results` artifact.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+109
View File
@@ -0,0 +1,109 @@
import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = {
name: "fixture_chair.nitro",
mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"),
};
test.beforeEach(async ({ page }) => {
await page.goto("/admin/attachment-harness");
await page
.getByText("Attach original file and resume", { exact: true })
.click();
});
test("attachment upload and double-click resume send one retry and refresh history", async ({
page,
}) => {
let retries = 0;
await page.route("**/api/admin/studio/import-attachment", async (route) => {
expect(route.request().method()).toBe("POST");
expect(route.request().postData()).toContain('name="classname"');
expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain(
'filename="fixture_chair.nitro"',
);
await route.fulfill({ json: { ok: true, attachmentId } });
});
await page.route("**/api/admin/studio/import-jobs", async (route) => {
retries++;
expect(route.request().method()).toBe("PATCH");
expect(route.request().postDataJSON()).toEqual({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
action: "retry",
attachments: { fixture_chair: attachmentId },
});
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } });
});
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
await expect(
page.getByText("Matching original file attached", { exact: true }),
).toBeVisible();
await page
.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
})
.dblclick();
await expect(page.getByLabel("Resumed count")).toHaveText("1");
expect(retries).toBe(1);
await expect(
page.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
}),
).toHaveCount(0);
});
for (const scenario of [
{
name: "owner rejection",
status: 403,
code: "forbidden",
message: "Could not attach the file.",
},
{
name: "mismatched furniture",
status: 400,
code: "mismatchedFile",
message: "This file belongs to different furniture.",
},
{
name: "network failure",
status: 0,
code: "",
message: "Network connection failed",
},
])
test(`attachment ${scenario.name} shows readable error without enabling resume`, async ({
page,
}) => {
let retries = 0;
await page.route("**/api/admin/studio/import-jobs", async (route) => {
retries++;
await route.fulfill({ json: { ok: true } });
});
await page.route("**/api/admin/studio/import-attachment", (route) =>
scenario.status
? route.fulfill({
status: scenario.status,
json: { ok: false, code: scenario.code },
})
: route.abort("failed"),
);
await page
.getByLabel("Choose the original .nitro file")
.setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect(
page.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
}),
).toHaveCount(0);
await expect(page.getByLabel("Resumed count")).toHaveText("0");
expect(retries).toBe(0);
});
+169
View File
@@ -0,0 +1,169 @@
import { expect, test } from "@playwright/test";
const key = "cms:draft:v1:7:article:new";
test("browser draft survives session failure and reload, restores explicitly and clears only after save", async ({
page,
}) => {
await page.route("**/fixture/actions/load-recovery", (route) =>
route.fulfill({ status: 401, body: "expired" }),
);
await page.route("**/fixture/actions/article", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: false, error: "Session expired" }),
}),
);
await page.goto("/admin/articles/recovery");
const title = page.locator('input[name="title"]');
await title.fill("Keep this unfinished article");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toContain("Keep this unfinished article");
await page.getByRole("button", { name: "Save article", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Session expired");
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
"Keep this unfinished article",
);
page.on("dialog", (dialog) => dialog.accept());
await page.reload();
await expect(title).toHaveValue("Community update");
await page
.getByRole("button", { name: "Restore browser copy", exact: true })
.click();
await expect(title).toHaveValue("Keep this unfinished article");
await page.unroute("**/fixture/actions/article");
await page.route("**/fixture/actions/article", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true }),
}),
);
await page.getByRole("button", { name: "Save article", exact: true }).click();
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toBeNull();
await page.reload();
await expect(
page.getByRole("button", { name: "Restore browser copy", exact: true }),
).toHaveCount(0);
});
test("pending recovery does not overwrite current edits and discard preserves them", async ({
page,
}) => {
await page.goto("/admin/articles/recovery");
const title = page.locator('input[name="title"]');
await title.fill("Older draft");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toContain("Older draft");
page.on("dialog", (dialog) => dialog.accept());
await page.reload();
await title.fill("New current edits");
await page
.getByRole("button", { name: "Discard browser copy", exact: true })
.click();
await expect(title).toHaveValue("New current edits");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toContain("New current edits");
});
test("unavailable browser storage leaves the form editable", async ({
page,
}) => {
await page.addInitScript(() => {
Storage.prototype.setItem = () => {
throw Error("quota");
};
});
await page.goto("/admin/articles/recovery");
await page.locator('input[name="title"]').fill("Still editable");
await expect(
page.getByText("Browser recovery is unavailable.", { exact: false }),
).toBeVisible();
await expect(page.locator('input[name="title"]')).toHaveValue(
"Still editable",
);
});
test("saving while recovery is pending enables recovery for subsequent edits", async ({
page,
}) => {
await page.route("**/fixture/actions/article", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true }),
}),
);
await page.goto("/admin/articles/recovery");
const title = page.locator('input[name="title"]');
await title.fill("Previous browser draft");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toContain("Previous browser draft");
page.on("dialog", (dialog) => dialog.accept());
await page.reload();
await expect(
page.getByRole("button", { name: "Restore browser copy", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Save article", exact: true }).click();
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toBeNull();
await title.fill("Edits after successful save");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
.toContain("Edits after successful save");
});
test("unmount flush retains an edit before the autosave interval", async ({
page,
}) => {
await page.goto("/admin/articles/recovery");
await page.clock.install();
await page.clock.pauseAt(new Date());
await page.locator('input[name="title"]').fill("Immediate final edit");
await page
.getByRole("button", { name: "Close editor fixture", exact: true })
.click();
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
"Immediate final edit",
);
});
test("event title and dates recover after reload and clear after successful update", async ({
page,
}) => {
const eventKey = "cms:draft:v1:7:event:12";
await page.route("**/fixture/actions/update-event", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true, data: { id: 12 } }),
}),
);
await page.goto("/admin/events/recovery");
const title = page.locator("#title");
const dates = page.locator('input[type="datetime-local"]');
await title.fill("Recovered community event");
await dates.nth(0).fill("2030-02-03T14:30");
await dates.nth(1).fill("2030-02-03T16:00");
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
.toContain("2030-02-03T14:30:00.000Z");
page.on("dialog", (dialog) => dialog.accept());
await page.reload();
await expect(title).toHaveValue("Original event");
await page
.getByRole("button", { name: "Restore browser copy", exact: true })
.click();
await expect(title).toHaveValue("Recovered community event");
await expect(dates.nth(0)).toHaveValue("2030-02-03T14:30");
await expect(dates.nth(1)).toHaveValue("2030-02-03T16:00");
const save = page.waitForRequest("**/fixture/actions/update-event");
await page.locator('button[type="submit"]').click();
expect((await save).postDataJSON()).toMatchObject({
id: 12,
title: "Recovered community event",
startsAt: "2030-02-03T14:30:00.000Z",
endsAt: "2030-02-03T16:00:00.000Z",
});
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
.toBeNull();
});
+224
View File
@@ -0,0 +1,224 @@
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import tailwind from "@tailwindcss/postcss";
import { build } from "esbuild";
import postcss from "postcss";
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
const fixtures = path.join(root, "e2e/ui/fixtures");
const port = Number(process.env.UI_TEST_PORT || 3107);
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw Error("Invalid UI_TEST_PORT");
const actions = new Set([
"@/actions/events",
"@/actions/admin-settings",
"@/actions/admin-table-views",
"@/actions/article-recovery",
"@/actions/admin-media",
]);
const bundle = await build({
entryPoints: [path.join(fixtures, "entry.tsx")],
bundle: true,
write: false,
outdir: path.join(root, "test-results/ui-fixture"),
format: "iife",
jsx: "automatic",
alias: { "@": path.join(root, "src") },
define: { "process.env.NODE_ENV": '"production"' },
plugins: [
{
name: "server-contracts",
setup(build) {
build.onResolve(
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
(args) => {
if (args.path === "next/navigation")
return { path: path.join(fixtures, "navigation.ts") };
if (args.path === "next/link")
return { path: path.join(fixtures, "next-link.tsx") };
if (actions.has(args.path))
return { path: path.join(fixtures, "server-actions.ts") };
throw Error(`Unexpected server action in UI fixture: ${args.path}`);
},
);
build.onResolve(
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
(args) => {
throw Error(
`Database or server dependency forbidden in UI fixture: ${args.path}`,
);
},
);
},
},
],
});
const globalCss = (
await postcss([tailwind({ base: root })]).process(
await readFile(path.join(root, "src/app/globals.css"), "utf8"),
{ from: path.join(root, "src/app/globals.css") },
)
).css;
const css =
globalCss +
"\n" +
bundle.outputFiles
.filter((file) => file.path.endsWith(".css"))
.map((file) => file.text)
.join("\n");
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
const html =
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
"a".repeat(64) +
'"><title>UI verification fixture</title><link rel="stylesheet" href="/fixture/styles.css"></head><body><div id="root"></div><script src="/fixture/app.js"></script></body></html>';
const cover =
'<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160"><rect width="720" height="160" fill="#334155"/><circle cx="620" cy="80" r="48" fill="#f59e0b"/></svg>';
const jobs = new Map();
const views = [];
const json = (res, value, status = 200) => {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(value));
};
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://127.0.0.1");
if (url.pathname === "/fixture/reset") {
jobs.clear();
views.length = 0;
return json(res, { ok: true });
}
if (url.pathname === "/health") return json(res, { ready: true });
if (url.pathname === "/fixture/styles.css") {
res.setHeader("content-type", "text/css");
return res.end(css);
}
if (url.pathname === "/fixture/app.js") {
res.setHeader("content-type", "application/javascript");
return res.end(js);
}
if (url.pathname === "/fixture/cover.svg") {
res.setHeader("content-type", "image/svg+xml");
return res.end(cover);
}
if (url.pathname.startsWith("/vendor/tinymce/")) {
const assetRoot = path.join(root, "node_modules/tinymce");
const asset = path.resolve(
assetRoot,
url.pathname.slice("/vendor/tinymce/".length),
);
if (!asset.startsWith(assetRoot + path.sep)) {
res.writeHead(400);
return res.end();
}
res.setHeader(
"content-type",
asset.endsWith(".css")
? "text/css"
: asset.endsWith(".js")
? "application/javascript"
: asset.endsWith(".woff")
? "font/woff"
: "application/octet-stream",
);
return res.end(await readFile(asset));
}
let body = {};
if (req.method === "POST" || req.method === "PATCH") {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1000000) throw Error("Fixture payload too large");
chunks.push(chunk);
}
body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
}
if (url.pathname === "/api/admin/studio/import-jobs") {
if (req.method === "PATCH") {
const job = jobs.get(body.id);
if (job) job.state = "cancelled";
return json(res, { ok: true, job });
}
return json(res, {
ok: true,
jobs: [...jobs.values()],
nextCursor: null,
});
}
if (url.pathname === "/api/admin/import/official/sync-all") {
if (!jobs.has(body.id))
jobs.set(body.id, {
id: body.id,
userId: 7,
createdAt: "2030-01-01T12:00:00.000Z",
updatedAt: "2030-01-01T12:00:00.000Z",
state: "queued",
translate: false,
syncKind: "official",
items: [
{
id: 0,
classname: "fixture_chair",
name: "Fixture chair",
description: "",
type: "flooritem",
category: "other",
revision: 1,
state: "pending",
},
],
});
await new Promise((resolve) => setTimeout(resolve, 150));
return json(res, { ok: true, job: jobs.get(body.id) });
}
if (url.pathname === "/api/media") return json(res, { files: [] });
if (url.pathname.startsWith("/fixture/actions/")) {
const name = url.pathname.split("/").at(-1);
if (name === "load-recovery")
return json(res, { version: 0, draft: null, revisions: [] });
if (name === "autosave" || name === "clear-recovery")
return json(res, { ok: true, version: 1 });
if (name === "list-views")
return json(res, { ok: true, data: { views } });
if (name === "save-view") {
views.push(body.view ?? body);
return json(res, { ok: true });
}
return json(res, { ok: true, data: {} });
}
if (
[
"/admin/attachment-harness",
"/admin/jobs-harness",
"/admin/settings",
"/admin/articles/new",
"/admin/articles/recovery",
"/admin/events/recovery",
"/admin/tickets/desk",
"/admin/import/official",
].includes(url.pathname)
) {
res.setHeader("content-type", "text/html");
return res.end(html);
}
res.writeHead(404);
res.end("Not found");
} catch (error) {
console.error(error);
res.writeHead(500);
res.end("Fixture error");
}
});
server.listen(port, "127.0.0.1", () =>
console.log(`Isolated UI fixture ready at http://127.0.0.1:${port}`),
);
for (const signal of ["SIGINT", "SIGTERM"])
process.on(signal, () => server.close(() => process.exit(0)));
+18
View File
@@ -0,0 +1,18 @@
import { useState } from "react";
import { ImportAttachmentRecovery } from "@/components/admin/studio/import-attachment-recovery";
export function AttachmentHarness() {
const [resumed, setResumed] = useState(0);
return (
<>
<ImportAttachmentRecovery
jobId="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
classname="fixture_chair"
disabled={false}
onResumed={async () => {
setResumed((value) => value + 1);
}}
/>
<output aria-label="Resumed count">{resumed}</output>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { NextIntlClientProvider } from "next-intl";
import { useState } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "sonner";
import { EventForm } from "@/app/admin/events/event-form";
import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
import { ArticleForm } from "@/components/admin/article-form";
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
import type { ArticleSaveResult } from "@/lib/article-input";
import { themePaletteCss } from "@/lib/theme-css";
import { PRESETS } from "@/lib/theme-presets";
import messages from "@/messages/en.json";
import { AttachmentHarness } from "./attachment-harness";
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
import { fixtureAction } from "./server-actions";
const theme = document.createElement("style");
theme.textContent = themePaletteCss(
":root.dark",
PRESETS["Atom (golden)"].dark,
);
document.head.appendChild(theme);
const route = window.location.pathname;
const root = document.getElementById("root");
if (!root) throw Error("Fixture root missing");
const rows = [
{
id: 1,
subject: "Account assistance",
creator: "SampleMember",
creatorId: 7,
category: "general",
messages: 2,
status: "open",
priority: "normal",
assignee: "Moderator",
date: "01/01/2030",
awaitingStaff: true,
replyAgeMinutes: 90,
},
{
id: 2,
subject: "Event question",
creator: "AnotherMember",
creatorId: 8,
category: "events",
messages: 1,
status: "waiting",
priority: "high",
assignee: "—",
date: "01/01/2030",
awaitingStaff: false,
replyAgeMinutes: 15,
},
];
const saveArticle = async (form: FormData): Promise<ArticleSaveResult> =>
fixtureAction("article", Object.fromEntries(form));
function ArticleFixture() {
const [visible, setVisible] = useState(true);
return (
<>
{route.includes("recovery") && (
<button type="button" onClick={() => setVisible(false)}>
Close editor fixture
</button>
)}
{visible && (
<ArticleForm
userId={route.includes("recovery") ? "7" : undefined}
action={saveArticle}
articleKey="new"
defaultValues={{
title: "Community update",
slug: "community-update",
image: "/fixture/cover.svg",
shortStory: "A short update for the community.",
fullStory:
"<p>Join the upcoming event and explore the hotel together.</p>",
status: "draft",
}}
/>
)}
</>
);
}
createRoot(root).render(
<NextIntlClientProvider
locale="en"
timeZone="UTC"
now={new Date("2030-01-01T12:00:00Z")}
messages={messages}
>
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
<h1 className="mb-5 text-2xl font-bold">
{route.includes("settings")
? "CMS settings"
: route.includes("tickets")
? "Support desk"
: route.includes("articles")
? "News editor"
: "Furniture synchronization"}
</h1>
{route.includes("attachment-harness") ? (
<AttachmentHarness />
) : route.includes("events/recovery") ? (
<EventForm
userId="7"
eventTypes={[
{ id: 1, name: "Community", slug: "community", color: "#123456" },
]}
defaultValues={{
id: 12,
title: "Original event",
description: "Event details",
typeId: 1,
status: "draft",
isRecurring: 0,
startsAt: new Date("2030-01-01T12:00:00Z"),
}}
/>
) : route.includes("jobs-harness") ? (
<FurnitureJobsHarness />
) : route.includes("settings") ? (
<CmsSettingsForm
canEdit
values={{ default_dark: "0", cms_hotel_name: "Example Hotel" }}
/>
) : route.includes("tickets") ? (
<TicketsTable
data={{ rows, total: 2, page: 1, perPage: 20, lastPage: 1 }}
/>
) : route.includes("articles") ? (
<ArticleFixture />
) : (
<FurnitureSyncQueue kind="official" />
)}
</main>
<Toaster />
</NextIntlClientProvider>,
);
@@ -0,0 +1,35 @@
import { useState } from "react";
import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs";
export function FurnitureJobsHarness() {
const [completions, setCompletions] = useState(0);
const history = useFurnitureJobs(
() => setCompletions((value) => value + 1),
true,
);
return (
<section aria-label="Import history hook">
<button
type="button"
onClick={() => {
void history.refresh();
void history.refresh();
}}
>
Refresh twice
</button>
<button type="button" onClick={() => void history.navigate("next")}>
Next page
</button>
<button type="button" onClick={() => void history.navigate("previous")}>
Previous page
</button>
<output aria-label="Page">{history.page}</output>
<output aria-label="Completions">{completions}</output>
<output aria-label="Loading">{String(history.loading)}</output>
<output aria-label="Jobs">
{history.jobs.map((job) => `${job.id}:${job.state}`).join(",")}
</output>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
const router = {
push: (url: string) => {
window.location.href = url;
},
replace: (url: string) => {
window.location.replace(url);
},
refresh: () => {},
};
export const useRouter = () => router;
export const usePathname = () => window.location.pathname;
export const useSearchParams = () =>
new URLSearchParams(window.location.search);
+7
View File
@@ -0,0 +1,7 @@
import type { AnchorHTMLAttributes } from "react";
export default function FixtureLink({
prefetch: _prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
return <a {...props} />;
}
+29
View File
@@ -0,0 +1,29 @@
export async function fixtureAction(name: string, input: unknown = {}) {
const response = await fetch(`/fixture/actions/${name}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw Error(`Fixture action failed: ${response.status}`);
return response.json();
}
export const saveManagedSettings = (input: unknown) =>
fixtureAction("settings", input);
export const listTableViews = (input: unknown) =>
fixtureAction("list-views", input);
export const saveTableView = (input: unknown) =>
fixtureAction("save-view", input);
export const deleteTableView = (input: unknown) =>
fixtureAction("delete-view", input);
export const loadArticleRecovery = (key: unknown) =>
fixtureAction("load-recovery", { key });
export const autosaveArticle = (...input: unknown[]) =>
fixtureAction("autosave", input);
export const clearArticleRecovery = (...input: unknown[]) =>
fixtureAction("clear-recovery", input);
export const uploadMediaAndReturn = () => fixtureAction("upload");
export const createEvent = (input: unknown) =>
fixtureAction("create-event", input);
export const updateEvent = (input: unknown) =>
fixtureAction("update-event", input);
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from "@playwright/test";
import {
furnitureCursorFixture,
furnitureJobFixture,
} from "../../src/test/furniture-jobs-fixtures";
test("history hook deduplicates refreshes, pages and reports completion once", async ({
page,
}) => {
let requests = 0;
let completed = false;
await page.route("**/api/admin/studio/import-jobs?*", async (route) => {
requests++;
const secondPage = new URL(route.request().url()).searchParams.has(
"before",
);
await new Promise((resolve) => setTimeout(resolve, 100));
await route.fulfill({
json: {
ok: true,
jobs: [
{
...furnitureJobFixture,
id: secondPage
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
: furnitureJobFixture.id,
state: completed ? "completed" : "queued",
},
],
nextCursor: secondPage ? null : furnitureCursorFixture,
},
});
});
await page.goto("/admin/jobs-harness");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
const initial = requests;
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
expect(requests).toBe(initial + 1);
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByLabel("Page", { exact: true })).toHaveText("2");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
);
await page.getByRole("button", { name: "Previous page" }).click();
await expect(page.getByLabel("Page", { exact: true })).toHaveText("1");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
completed = true;
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
});
test("history polling pauses while hidden and refreshes on visibility return", async ({
page,
}) => {
let requests = 0;
await page.clock.install();
await page.route("**/api/admin/studio/import-jobs?*", (route) => {
requests++;
return route.fulfill({
json: { ok: true, jobs: [furnitureJobFixture], nextCursor: null },
});
});
await page.goto("/admin/jobs-harness");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
});
const hiddenCount = requests;
await page.clock.fastForward(60000);
expect(requests).toBe(hiddenCount);
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
});
await expect.poll(() => requests).toBe(hiddenCount + 1);
});
+131
View File
@@ -0,0 +1,131 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, type Page, test } from "@playwright/test";
async function accessible(page: Page, sandboxPreview = false) {
const builder = new AxeBuilder({ page });
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
if (sandboxPreview) builder.exclude("iframe[sandbox]");
const result = await builder
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
result.violations.map(({ id, impact, nodes }) => ({
id,
impact,
nodes: nodes.map((node) => ({
target: node.target,
summary: node.failureSummary,
})),
})),
).toEqual([]);
}
test.beforeEach(async ({ page, baseURL, request }) => {
await request.post("/fixture/reset");
await page.clock.setFixedTime(new Date("2030-01-01T12:00:00Z"));
await page.route("**/*", (route) =>
new URL(route.request().url()).origin === baseURL
? route.continue()
: route.abort(),
);
page.on("pageerror", (error) => {
throw error;
});
});
test("settings switches have names and remain keyboard operable", async ({
page,
}) => {
await page.goto("/admin/settings");
const toggle = page.getByRole("switch", {
name: "Dark mode by default",
exact: true,
});
await expect(toggle).not.toBeChecked();
await toggle.focus();
await page.keyboard.press("Space");
await expect(toggle).toBeChecked();
await accessible(page);
const group = page.locator("section").filter({ has: toggle });
await expect(group).toHaveScreenshot("settings-switches.png");
const response = page.waitForResponse((response) =>
response.url().endsWith("/fixture/actions/settings"),
);
await page
.getByRole("button", { name: "Save settings", exact: true })
.click();
expect((await response).ok()).toBe(true);
await expect(
page.getByRole("button", { name: "Save settings", exact: true }),
).toBeDisabled();
});
test("article form and preview dialog pass accessibility with focus return", async ({
page,
}) => {
await page.goto("/admin/articles/new");
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
await accessible(page);
await page.locator('input[name="title"]').fill("Accessible community update");
const preview = page.getByRole("button", { name: "Preview", exact: true });
await preview.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAccessibleName("Article preview");
await expect(dialog.locator("iframe")).toHaveAttribute("title", /.+/);
await accessible(page, true);
await expect(dialog).toHaveScreenshot("article-preview.png");
// Keyboard events inside the sandbox do not bubble to the parent dialog.
await page.keyboard.press("Tab");
await expect(
dialog.getByRole("button", { name: "Close", exact: true }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(preview).toBeFocused();
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
});
test("support table filters and saved-view popover stay accessible", async ({
page,
}) => {
await page.goto("/admin/tickets/desk");
await expect(
page.getByRole("link", { name: "Account assistance" }),
).toBeVisible();
await accessible(page);
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
await page
.getByRole("button", { name: "Assigned to me", exact: true })
.click();
await expect(page).toHaveURL(/filter_assignee=mine/);
await page.getByRole("button", { name: "Saved views", exact: true }).click();
await expect(page.getByRole("textbox", { name: "View name" })).toBeVisible();
await accessible(page);
await expect(page.getByRole("main")).toHaveScreenshot(
"ticket-saved-views.png",
);
});
test("queued synchronization survives reload without duplicate submission", async ({
page,
}) => {
await page.goto("/admin/import/official");
const requests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().endsWith("/sync-all"))
requests.push(request.postData() || "");
});
await page
.getByRole("button", { name: "Start synchronization", exact: true })
.dblclick();
await expect(
page.getByRole("status").filter({ hasText: "Synchronization saved" }),
).toBeVisible();
expect(requests).toHaveLength(1);
await page.reload();
await page.locator("details details > summary").first().click();
await expect(page.getByText("fixture_chair", { exact: true })).toBeVisible();
await accessible(page);
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
});
+8 -1
View File
@@ -36,7 +36,10 @@
"analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"test:ui": "playwright test --config playwright.ui.config.ts",
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs"
},
"dependencies": {
"@base-ui/react": "1.8.0",
@@ -45,6 +48,7 @@
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.17",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-virtual": "3.14.10",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -79,6 +83,7 @@
"zod": "4.5.4"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "7.29.8",
"@biomejs/biome": "2.5.12",
"@playwright/test": "1.62.1",
@@ -90,6 +95,8 @@
"@types/react-dom": "19.2.7",
"@vitest/coverage-v8": "5.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.2",
"msw": "2.15.0",
"pino-pretty": "13.1.3",
"postcss": "8.5.28",
"tailwindcss": "4.3.3",
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: "**/ui/**",
fullyParallel: false,
workers: 1,
retries: 1,
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig } from "@playwright/test";
const port = Number(process.env.UI_TEST_PORT || 3107);
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw Error("Invalid UI_TEST_PORT");
const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./e2e/ui",
testMatch: "**/*.spec.ts",
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
timeout: 45000,
expect: {
timeout: 10000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.002,
},
},
snapshotPathTemplate: `{testDir}/__screenshots__/${process.platform}/{projectName}/{testFilePath}/{arg}{ext}`,
reporter: [
["list"],
["html", { outputFolder: "playwright-report/ui", open: "never" }],
],
outputDir: "test-results/ui",
use: {
baseURL,
locale: "en-US",
timezoneId: "UTC",
colorScheme: "dark",
contextOptions: { reducedMotion: "reduce" },
trace: "retain-on-failure",
screenshot: "only-on-failure",
launchOptions: process.env.UI_TEST_BROWSER_PATH
? { executablePath: process.env.UI_TEST_BROWSER_PATH }
: {},
},
projects: [
{
name: "desktop",
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
},
{
name: "mobile",
use: {
browserName: "chromium",
viewport: { width: 390, height: 844 },
isMobile: true,
deviceScaleFactor: 1,
hasTouch: true,
},
},
],
webServer: {
command: "node e2e/ui/fixture-server.mjs",
url: `${baseURL}/health`,
timeout: 60000,
reuseExistingServer: false,
env: { UI_TEST_PORT: String(port) },
},
});
+426 -5
View File
@@ -31,6 +31,9 @@ importers:
'@hookform/resolvers':
specifier: 5.9.1
version: 5.9.1([email protected]([email protected]))([email protected])
'@tanstack/react-query':
specifier: 5.102.8
version: 5.102.8([email protected])
'@tanstack/react-virtual':
specifier: 3.14.10
version: 3.14.10([email protected]([email protected]))([email protected])
@@ -128,6 +131,9 @@ importers:
specifier: 4.5.4
version: 4.5.4
devDependencies:
'@axe-core/playwright':
specifier: 4.13.0
version: 4.13.0([email protected])
'@babel/parser':
specifier: 7.29.8
version: 7.29.8
@@ -161,6 +167,12 @@ importers:
drizzle-kit:
specifier: 0.31.10
version: 0.31.10
esbuild:
specifier: 0.28.2
version: 0.28.2
msw:
specifier: 2.15.0
version: 2.15.0(@types/[email protected])([email protected])
pino-pretty:
specifier: 13.1.3
version: 13.1.3
@@ -178,7 +190,7 @@ importers:
version: 7.0.2
vitest:
specifier: 5.0.0
version: 5.0.0(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 5.0.0(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages:
@@ -208,6 +220,11 @@ packages:
nodemailer:
optional: true
'@axe-core/[email protected]':
resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
peerDependencies:
playwright-core: '>= 1.0.0'
'@babel/[email protected]':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -993,6 +1010,41 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/[email protected]':
resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/[email protected]':
resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/[email protected]':
resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/[email protected]':
resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/[email protected]':
resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@ioredis/[email protected]':
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
@@ -1012,6 +1064,10 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mswjs/[email protected]':
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
engines: {node: '>=18'}
'@next/[email protected]':
resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
@@ -1071,6 +1127,18 @@ packages:
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@open-draft/[email protected]':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@open-draft/[email protected]':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@open-draft/[email protected]':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/[email protected]':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@otplib/[email protected]':
resolution: {integrity: sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==}
@@ -1656,6 +1724,14 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
'@tanstack/[email protected]':
resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==}
peerDependencies:
react: ^18 || ^19
'@tanstack/[email protected]':
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
peerDependencies:
@@ -1692,6 +1768,12 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/[email protected]':
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
'@types/[email protected]':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/[email protected]':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1846,6 +1928,14 @@ packages:
'@vitest/[email protected]':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -1865,6 +1955,10 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
[email protected]:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
engines: {node: '>=6.0.0'}
@@ -1886,9 +1980,17 @@ packages:
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
[email protected]:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
[email protected]:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
[email protected]:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1903,6 +2005,13 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
[email protected]:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
[email protected]:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
[email protected]:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -1910,6 +2019,10 @@ packages:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -2058,6 +2171,9 @@ packages:
sqlite3:
optional: true
[email protected]:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
[email protected]:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -2082,6 +2198,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -2098,6 +2218,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
[email protected]:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
[email protected]:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
[email protected]:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -2135,6 +2264,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
[email protected]:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
[email protected]:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
@@ -2145,9 +2278,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
[email protected]:
resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
[email protected]:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
[email protected]:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
[email protected]:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@@ -2182,10 +2322,17 @@ packages:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
[email protected]:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -2445,10 +2592,24 @@ packages:
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]:
resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
typescript: '>= 4.8.x'
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-TN+kO1/oOc8UzDW5N3vSDncBcv9WyNnQQe/NFMcFWq6G1+zVeYUkGvkYpQ/S8wb8vKtUD7i78dIaY0cv+cmPRA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
[email protected]:
resolution: {integrity: sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==}
engines: {node: '>= 8.0'}
@@ -2534,12 +2695,18 @@ packages:
[email protected]:
resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==}
[email protected]:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
[email protected]:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
[email protected]:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
[email protected]:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2672,6 +2839,10 @@ packages:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -2691,6 +2862,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
[email protected]:
resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
[email protected]:
resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2724,6 +2898,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
[email protected]:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
[email protected]:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
@@ -2739,6 +2916,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@@ -2780,12 +2961,27 @@ packages:
[email protected]:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
[email protected]:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
[email protected]:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
[email protected]:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
[email protected]:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
@@ -2810,6 +3006,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
[email protected]:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -2870,6 +3070,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
@@ -2886,6 +3090,9 @@ packages:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
engines: {node: '>=22.19.0'}
[email protected]:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
[email protected]:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -3031,6 +3238,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
[email protected]:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -3041,11 +3252,23 @@ packages:
[email protected]:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
[email protected]:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
@@ -3076,6 +3299,11 @@ snapshots:
preact: 10.24.3
preact-render-to-string: 6.5.11([email protected])
'@axe-core/[email protected]([email protected])':
dependencies:
axe-core: 4.13.0
playwright-core: 1.62.1
'@babel/[email protected]': {}
'@babel/[email protected]': {}
@@ -3536,6 +3764,33 @@ snapshots:
'@img/[email protected]':
optional: true
'@inquirer/[email protected]': {}
'@inquirer/[email protected](@types/[email protected])':
dependencies:
'@inquirer/core': 12.0.3(@types/[email protected])
'@inquirer/type': 4.1.1(@types/[email protected])
optionalDependencies:
'@types/node': 26.4.1
'@inquirer/[email protected](@types/[email protected])':
dependencies:
'@inquirer/ansi': 2.0.8
'@inquirer/figures': 2.0.9
'@inquirer/type': 4.1.1(@types/[email protected])
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 26.4.1
'@inquirer/[email protected]': {}
'@inquirer/[email protected](@types/[email protected])':
optionalDependencies:
'@types/node': 26.4.1
'@ioredis/[email protected]': {}
'@jridgewell/[email protected]':
@@ -3557,6 +3812,15 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@mswjs/[email protected]':
dependencies:
'@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0
'@open-draft/until': 2.1.0
is-node-process: 1.2.0
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@next/[email protected]': {}
'@next/[email protected]':
@@ -3585,6 +3849,17 @@ snapshots:
'@noble/[email protected]': {}
'@open-draft/[email protected]': {}
'@open-draft/[email protected]': {}
'@open-draft/[email protected]':
dependencies:
is-node-process: 1.2.0
outvariant: 1.4.3
'@open-draft/[email protected]': {}
'@otplib/[email protected]': {}
'@otplib/[email protected]':
@@ -4014,6 +4289,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.3.3
'@tanstack/[email protected]': {}
'@tanstack/[email protected]([email protected])':
dependencies:
'@tanstack/query-core': 5.102.8
react: 19.2.8
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.17.8
@@ -4052,6 +4334,12 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/[email protected]':
dependencies:
'@types/node': 26.4.1
'@types/[email protected]': {}
'@types/[email protected]':
optional: true
@@ -4125,7 +4413,7 @@ snapshots:
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 5.0.0(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
vitest: 5.0.0(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitest/[email protected]': {}
@@ -4133,17 +4421,24 @@ snapshots:
dependencies:
'@vitest/istanbul-lib-coverage': 1.0.1
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 1.2.3
optionalDependencies:
msw: 2.15.0(@types/[email protected])([email protected])
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
'@vitest/[email protected]': {}
[email protected]: {}
[email protected]:
dependencies:
color-convert: 2.0.1
[email protected]:
dependencies:
tslib: 2.8.1
@@ -4160,6 +4455,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -4176,8 +4473,16 @@ snapshots:
dependencies:
clsx: 2.1.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
[email protected]: {}
[email protected]: {}
@@ -4194,10 +4499,18 @@ snapshots:
- '@types/react'
- '@types/react-dom'
[email protected]:
dependencies:
color-name: 1.1.4
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -4247,6 +4560,8 @@ snapshots:
optionalDependencies:
mysql2: 3.24.3(@types/[email protected])
[email protected]: {}
[email protected]:
dependencies:
once: 1.4.0
@@ -4318,6 +4633,8 @@ snapshots:
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.9
@@ -4330,6 +4647,16 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fast-string-truncated-width: 3.0.3
[email protected]:
dependencies:
fast-string-width: 3.0.2
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.7
@@ -4362,6 +4689,8 @@ snapshots:
dependencies:
is-property: 1.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -4370,8 +4699,15 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.2
[email protected]: {}
[email protected](@noble/[email protected]):
@@ -4412,10 +4748,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
is-extglob: 2.1.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -4627,6 +4967,31 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
dependencies:
'@inquirer/confirm': 6.3.2(@types/[email protected])
'@mswjs/interceptors': 0.41.9
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.14.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.2
type-fest: 5.9.0
until-async: 3.0.2
yargs: 17.7.3
optionalDependencies:
typescript: 7.0.2
transitivePeerDependencies:
- '@types/node'
[email protected]:
dependencies:
'@borewit/text-codec': 0.2.2
@@ -4642,6 +5007,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected](@types/[email protected]):
dependencies:
'@types/node': 26.4.1
@@ -4735,12 +5102,16 @@ snapshots:
'@otplib/totp': 13.5.0
'@otplib/uri': 13.5.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
entities: 8.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -4883,6 +5254,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -4894,6 +5267,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@oxc-project/types': 0.148.0
@@ -4933,6 +5308,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected](@types/[email protected]):
@@ -4970,6 +5347,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
atomic-sleep: 1.0.0
@@ -5003,12 +5382,26 @@ snapshots:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
[email protected]:
dependencies:
safe-buffer: 5.1.2
[email protected]:
dependencies:
ansi-regex: 5.0.1
[email protected]: {}
[email protected]:
@@ -5022,6 +5415,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -5073,6 +5468,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
[email protected]:
dependencies:
tagged-tag: 1.0.0
[email protected]:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2
@@ -5102,6 +5501,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
dependencies:
react: 19.2.8
@@ -5146,10 +5547,10 @@ snapshots:
tsx: 4.23.13
yaml: 2.9.0
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitest/mocker': 5.0.0([email protected](@types/[email protected])([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -5200,13 +5601,33 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
[email protected]: {}
[email protected]:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
[email protected]: {}
+1
View File
@@ -3,6 +3,7 @@ allowBuilds:
sharp: true
"@parcel/watcher": true
"@swc/core": true
msw: false
minimumReleaseAge: 1440
+1
View File
@@ -63,6 +63,7 @@ export default async function EditArticle({
</p>
) : null}
<ArticleForm
userId={String(session.user.id)}
articleKey={String(article.id)}
baseToken={articleEditToken(article)}
action={async (fd) => {
+1 -1
View File
@@ -39,7 +39,7 @@ export default async function NewArticle({
Error: {error}
</p>
) : null}
<ArticleForm action={createArticle} />
<ArticleForm userId={String(session.user.id)} action={createArticle} />
</main>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function CreateEventPage() {
</p>
</div>
<EventForm eventTypes={eventTypes} />
<EventForm userId={String(session.user.id)} eventTypes={eventTypes} />
</div>
);
}
+217 -173
View File
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -19,7 +20,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useServerAction } from "@/hooks/use-server-action";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import { publicationIssues } from "@/lib/publication-preflight";
import type {
CreateEventInput,
@@ -34,6 +37,7 @@ interface EventType {
}
interface EventFormProps {
userId?: string;
eventTypes: EventType[];
defaultValues?: Partial<CreateEventInput> & { id?: number };
}
@@ -51,9 +55,14 @@ interface EventFormValues {
endsAt?: Date | null;
}
export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
export function EventForm({
eventTypes,
defaultValues,
userId,
}: EventFormProps) {
const t = useTranslations("pages.admin.events");
const tAction = useTranslations("pages.admin.actions");
const router = useRouter();
const [preview, setPreview] = useState(false);
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
@@ -69,9 +78,28 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
},
});
const dirty = form.formState.isDirty;
useUnsavedChanges(dirty, tAction("leaveUnsaved"));
const recovery = useFormRecovery({
userId,
kind: "event",
record: String(defaultValues?.id ?? "new"),
dirty,
snapshot: () => JSON.parse(JSON.stringify(form.getValues())),
restore: (payload) => {
const restored = {
...payload,
startsAt: payload.startsAt ? new Date(String(payload.startsAt)) : null,
endsAt: payload.endsAt ? new Date(String(payload.endsAt)) : null,
} as EventFormValues;
form.reset(restored, { keepDefaultValues: true });
},
});
const values = form.watch();
const issues = publicationIssues({ kind: "event", ...values });
function onSubmit(data: EventFormValues) {
if (isPending) return;
recovery.flush();
if (
data.status === "published" &&
publicationIssues({ kind: "event", ...data }).some(
@@ -87,15 +115,21 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
}),
{
successMessage: t("toastUpdated"),
onSuccess: () => {
recovery.clear();
form.reset(data);
router.refresh();
},
},
);
} else {
run(() => createEvent(data as CreateEventInput), {
successMessage: t("toastCreated"),
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined;
if (data?.id) {
window.location.href = `/admin/events/${data.id}`;
recovery.clear();
form.reset(data);
if (result.id) {
router.push(`/admin/events/${result.id}`);
}
},
});
@@ -103,178 +137,188 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>
{isEditing ? t("formEditTitle") : t("formDetailsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{t("formTitle")}</Label>
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">
{form.formState.errors.title.message}
</p>
)}
</div>
<form
onChangeCapture={recovery.changed}
onSubmit={form.handleSubmit(onSubmit)}
>
<fieldset disabled={isPending} className="contents">
<Card>
<CardHeader>
<CardTitle>
{isEditing ? t("formEditTitle") : t("formDetailsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{recovery.panel}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{t("formTitle")}</Label>
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">
{form.formState.errors.title.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="typeId">{t("formEventType")}</Label>
<Select
value={String(form.watch("typeId"))}
onValueChange={(v) => form.setValue("typeId", Number(v))}
>
<SelectTrigger>
<SelectValue placeholder={t("formSelectType")} />
</SelectTrigger>
<SelectContent>
{eventTypes.map((et) => (
<SelectItem key={et.id} value={String(et.id)}>
{et.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("formDescription")}</Label>
<Textarea
id="description"
rows={4}
{...form.register("description")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>{t("formStartsAt")}</Label>
<DateTimePicker
value={form.watch("startsAt")}
onChange={(d) =>
form.setValue("startsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectStart")}
/>
</div>
<div className="space-y-2">
<Label>{t("formEndsAt")}</Label>
<DateTimePicker
value={form.watch("endsAt")}
onChange={(d) =>
form.setValue("endsAt", d ?? null, { shouldDirty: true })
}
placeholder={t("formSelectEnd")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">{t("formMaxPlayers")}</Label>
<Input
id="maxPlayers"
type="number"
{...form.register("maxPlayers")}
placeholder={t("formUnlimited")}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">{t("formRoomId")}</Label>
<Input
id="roomId"
type="number"
{...form.register("roomId")}
placeholder={t("formOptional")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">{t("colStatus")}</Label>
<Select
value={form.watch("status")}
onValueChange={(v) =>
form.setValue("status", v as CreateEventInput["status"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">{t("statusDraft")}</SelectItem>
<SelectItem value="published">
{t("statusPublished")}
</SelectItem>
<SelectItem value="cancelled">
{t("statusCancelled")}
</SelectItem>
<SelectItem value="completed">
{t("statusCompleted")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">{t("formImageUrl")}</Label>
<Input
id="image"
{...form.register("image")}
placeholder="https://..."
/>
</div>
<PublicationPreflight
issues={issues}
paths={[
"/events",
...(defaultValues?.id ? [`/events/${defaultValues.id}`] : []),
]}
/>
<ArticlePreview
title={tAction("preview")}
data={
preview
? {
title: values.title,
image: values.image ?? "",
summary: values.description,
body: "",
<div className="space-y-2">
<Label htmlFor="typeId">{t("formEventType")}</Label>
<Select
value={String(form.watch("typeId"))}
onValueChange={(v) =>
form.setValue("typeId", Number(v), { shouldDirty: true })
}
: null
}
onClose={() => setPreview(false)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreview(true)}
>
{tAction("preview")}
</Button>
<Button
type="submit"
disabled={
isPending ||
(values.status === "published" &&
issues.some((issue) => issue.severity === "error"))
>
<SelectTrigger>
<SelectValue placeholder={t("formSelectType")} />
</SelectTrigger>
<SelectContent>
{eventTypes.map((et) => (
<SelectItem key={et.id} value={String(et.id)}>
{et.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("formDescription")}</Label>
<Textarea
id="description"
rows={4}
{...form.register("description")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>{t("formStartsAt")}</Label>
<DateTimePicker
value={form.watch("startsAt")}
onChange={(d) =>
form.setValue("startsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectStart")}
/>
</div>
<div className="space-y-2">
<Label>{t("formEndsAt")}</Label>
<DateTimePicker
value={form.watch("endsAt")}
onChange={(d) =>
form.setValue("endsAt", d ?? null, { shouldDirty: true })
}
placeholder={t("formSelectEnd")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxPlayers">{t("formMaxPlayers")}</Label>
<Input
id="maxPlayers"
type="number"
{...form.register("maxPlayers")}
placeholder={t("formUnlimited")}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roomId">{t("formRoomId")}</Label>
<Input
id="roomId"
type="number"
{...form.register("roomId")}
placeholder={t("formOptional")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">{t("colStatus")}</Label>
<Select
value={form.watch("status")}
onValueChange={(v) =>
form.setValue("status", v as CreateEventInput["status"], {
shouldDirty: true,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">{t("statusDraft")}</SelectItem>
<SelectItem value="published">
{t("statusPublished")}
</SelectItem>
<SelectItem value="cancelled">
{t("statusCancelled")}
</SelectItem>
<SelectItem value="completed">
{t("statusCompleted")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">{t("formImageUrl")}</Label>
<Input
id="image"
{...form.register("image")}
placeholder="https://..."
/>
</div>
<PublicationPreflight
issues={issues}
paths={[
"/events",
...(defaultValues?.id ? [`/events/${defaultValues.id}`] : []),
]}
/>
<ArticlePreview
title={tAction("preview")}
data={
preview
? {
title: values.title,
image: values.image ?? "",
summary: values.description,
body: "",
}
: null
}
>
{isPending
? t("formSaving")
: isEditing
? t("formUpdate")
: t("formCreate")}
</Button>
</div>
</CardContent>
</Card>
onClose={() => setPreview(false)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreview(true)}
>
{tAction("preview")}
</Button>
<Button
type="submit"
disabled={
isPending ||
(values.status === "published" &&
issues.some((issue) => issue.severity === "error"))
}
>
{isPending
? t("formSaving")
: isEditing
? t("formUpdate")
: t("formCreate")}
</Button>
</div>
</CardContent>
</Card>
</fieldset>
</form>
);
}
@@ -1,4 +1,4 @@
import { apiError, apiOk } from "@/lib/api";
import { apiError, apiJson, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
@@ -28,9 +28,16 @@ export const POST = withAdmin(
);
return apiOk({ attachmentId });
} catch (error) {
return apiError(
error instanceof Error ? error.message : "Invalid .nitro file",
400,
return apiJson(
{
error: "Invalid original .nitro file",
code:
error instanceof Error &&
error.message.startsWith("This bundle does not belong")
? "mismatchedFile"
: "invalidFile",
},
{ status: 400 },
);
}
},
@@ -4,6 +4,7 @@ import { beforeEach, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
const mocks = vi.hoisted(() => ({
attachment: vi.fn(),
guard: vi.fn(),
create: vi.fn(),
list: vi.fn(),
@@ -40,6 +41,10 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
vi.mock("@/lib/services/furni-attachment", () => ({
readFurnitureAttachment: mocks.attachment,
}));
import { GET, PATCH, POST } from "./route";
const item = {
@@ -179,3 +184,53 @@ it("rejects malformed history cursors", async () => {
).status,
).toBe(400);
});
it("validates recovery attachment ownership and classname before queueing", async () => {
const id = randomUUID(),
attachmentId = randomUUID();
mocks.attachment.mockResolvedValue(Buffer.from("valid"));
mocks.retry.mockResolvedValue({ id });
expect(
(
await PATCH(
request({ id, action: "retry", attachments: { chair: attachmentId } }),
ctx,
)
).status,
).toBe(200);
expect(mocks.attachment).toHaveBeenCalledWith(attachmentId, "chair", 7);
expect(mocks.retry).toHaveBeenCalledWith(id, 7, { chair: attachmentId });
});
it("rejects foreign or mismatched recovery files without retrying", async () => {
mocks.retry.mockClear();
mocks.attachment.mockRejectedValue(Error("owner mismatch"));
expect(
(
await PATCH(
request({
id: randomUUID(),
action: "retry",
attachments: { chair: randomUUID() },
}),
ctx,
)
).status,
).toBe(400);
expect(mocks.retry).not.toHaveBeenCalled();
});
it("rejects invalid recovery attachment paths", async () => {
mocks.retry.mockClear();
expect(
(
await PATCH(
request({
id: randomUUID(),
action: "retry",
attachments: { "../chair": randomUUID() },
}),
ctx,
)
).status,
).toBe(400);
expect(mocks.retry).not.toHaveBeenCalled();
});
+37 -5
View File
@@ -1,12 +1,13 @@
import { after } from "next/server";
import { z } from "zod";
import { apiError, apiOk } from "@/lib/api";
import { apiError, apiJson, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { getRequestId } from "@/lib/foundation/request-context";
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 { readFurnitureAttachment } from "@/lib/services/furni-attachment";
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
@@ -115,10 +116,41 @@ export const PATCH = withAdmin(
"Background import queue is temporarily unavailable",
503,
);
const job = await new ImportJobStore().retry(
body.id,
ctx.session.user.id,
);
const parsed = z
.record(z.string(), z.uuid())
.refine(
(value) =>
Object.keys(value).length <= 500 &&
(!Object.keys(value).length ||
validateClassnames(Object.keys(value))),
)
.safeParse(body.attachments ?? {});
if (!parsed.success) return apiError("Invalid recovery attachments", 400);
const attachments = parsed.data;
let job: Awaited<ReturnType<ImportJobStore["retry"]>>;
try {
for (const [classname, attachmentId] of Object.entries(attachments))
await readFurnitureAttachment(
attachmentId,
classname,
ctx.session.user.id,
);
const store = new ImportJobStore();
job = Object.keys(attachments).length
? await store.retry(body.id, ctx.session.user.id, attachments)
: await store.retry(body.id, ctx.session.user.id);
} catch (error) {
const message = error instanceof Error ? error.message : "";
const code = message.startsWith("Recovery already started")
? "alreadyStarted"
: message.includes("not eligible")
? "notEligible"
: "invalidFile";
return apiJson(
{ error: "Cannot resume with these attachments", code },
{ status: 400 },
);
}
if (!job)
return apiError(
"No safe remaining items to retry, or import job not found",
+29 -1
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input";
@@ -17,11 +18,13 @@ import { RichText } from "./rich-text";
export function ArticleForm({
action,
userId,
defaultValues,
articleKey = "new",
baseToken = "",
}: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
userId?: string;
edit?: boolean;
articleKey?: string;
baseToken?: string;
@@ -45,6 +48,7 @@ export function ArticleForm({
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
localRecovery.changed();
editVersion.current++;
setDirty(true);
}
@@ -90,6 +94,24 @@ export function ArticleForm({
dirty,
restoreDraft,
);
const localRecovery = useFormRecovery({
serverDraft: recovery.draft,
userId,
kind: "article",
record: articleKey,
dirty,
snapshot: () => ({
title,
slug: slugTouched ? slug : slugify(title),
image,
shortStory: summary,
fullStory: body,
status,
publishAt,
}),
restore: (payload) =>
restoreDraft({ ...payload, baseToken } as ArticleDraft, false),
});
useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -126,6 +148,7 @@ export function ArticleForm({
if (!Number.isNaN(date.getTime()))
data.set("publishAt", date.toISOString());
}
localRecovery.flush();
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(null);
@@ -139,7 +162,10 @@ export function ArticleForm({
setFieldErrors(result.fieldErrors ?? {});
return;
}
if (editVersion.current === submittedVersion) setDirty(false);
if (result?.ok && editVersion.current === submittedVersion) {
setDirty(false);
localRecovery.clear();
}
if (result?.ok) await recovery.clear().catch(() => {});
if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo);
@@ -164,6 +190,7 @@ export function ArticleForm({
>
<fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} />
{localRecovery.panel}
{recovery.panel}
{saveError && (
<p role="alert" className="text-sm">
@@ -282,6 +309,7 @@ export function ArticleForm({
{t("body")}
</span>
<RichText
ariaLabel={t("body")}
key={bodyVersion}
onChange={() => {
markDirty();
@@ -82,6 +82,7 @@ export function useArticleRecovery(
};
}, [key, form, saving, reload]);
return {
draft: recovery?.draft?.payload,
wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current),
panel: (
+8 -2
View File
@@ -395,7 +395,10 @@ export function DataTable<T extends { id: number | string }>({
value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger
aria-label={col.label}
className="h-8 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -479,7 +482,10 @@ export function DataTable<T extends { id: number | string }>({
value={String(data.perPage)}
onValueChange={handlePerPageChange}
>
<SelectTrigger className="w-[100px]">
<SelectTrigger
aria-label={t("perPage", { count: data.perPage })}
className="w-[100px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
+5 -1
View File
@@ -56,12 +56,14 @@ export function RichText({
name,
defaultValue = "",
placeholder,
ariaLabel,
rows = 10,
onChange,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
ariaLabel?: string;
rows?: number;
onChange?: () => void;
}) {
@@ -83,6 +85,7 @@ export function RichText({
.init({
selector,
menubar: false,
iframe_attrs: { title: ariaLabel || name },
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
@@ -127,13 +130,14 @@ export function RichText({
/* noop */
}
};
}, [editorId, rows]);
}, [editorId, rows, ariaLabel, name]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
aria-label={ariaLabel || name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
@@ -0,0 +1,266 @@
import { onlineManager, QueryObserver } from "@tanstack/react-query";
import { delay, HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import {
furnitureCursorFixture,
furnitureJobFixture,
} from "@/test/furniture-jobs-fixtures";
import {
createFurnitureJobsClient,
furnitureJobsQueryOptions,
readFurnitureJobResponse,
} from "./furniture-jobs-query";
const endpoint = "http://localhost/api/admin/studio/import-jobs";
const server = setupServer();
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
function client() {
const value = createFurnitureJobsClient();
clients.push(value);
return value;
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(async () => {
await Promise.all(
clients.map(async (value) => {
await value.cancelQueries();
value.clear();
}),
);
clients.length = 0;
server.resetHandlers();
});
afterAll(() => server.close());
describe("furniture history HTTP query", () => {
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
let calls = 0;
let release: (() => void) | undefined;
server.use(
http.get(endpoint, async () => {
calls++;
await new Promise<void>((resolve) => {
release = resolve;
});
return HttpResponse.json({
ok: true,
jobs: [furnitureJobFixture],
nextCursor: furnitureCursorFixture,
});
}),
);
const cache = client();
const options = furnitureJobsQueryOptions(true, null);
const first = cache.fetchQuery(options);
const second = cache.fetchQuery(options);
await vi.waitFor(() => expect(calls).toBe(1));
release?.();
const [a, b] = await Promise.all([first, second]);
expect(a).toEqual(b);
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
expect(cache.getQueryData(options.queryKey)).toEqual(a);
});
it("keeps different pages and mounted history clients isolated", async () => {
const urls: string[] = [];
server.use(
http.get(endpoint, ({ request }) => {
urls.push(request.url);
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
}),
);
const first = client(),
second = client();
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
await first.fetchQuery(
furnitureJobsQueryOptions(true, furnitureCursorFixture),
);
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
expect(urls).toHaveLength(3);
expect(new URL(urls[1]).searchParams.get("before")).toBe(
furnitureCursorFixture,
);
first.clear();
expect(
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
).toEqual({ jobs: [], nextCursor: null });
});
it.each([403, 500])(
"surfaces HTTP %i without automatic retries or a false empty result",
async (status) => {
let calls = 0;
server.use(
http.get(endpoint, () => {
calls++;
return HttpResponse.json(
{ error: "History unavailable" },
{ status },
);
}),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
status,
message: "History unavailable",
});
expect(calls).toBe(1);
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
},
);
it.each([
{ ok: true, jobs: null },
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
{
ok: true,
jobs: [
{
...furnitureJobFixture,
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
},
],
},
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
{ ok: true, jobs: [], nextCursor: "../other-account" },
{
ok: true,
jobs: [],
nextCursor:
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
},
])("rejects malformed successful payloads", async (payload) => {
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
await expect(cache.fetchQuery(options)).rejects.toThrow(
"Invalid import history response",
);
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
});
it("reports invalid JSON as a transport failure", async () => {
server.use(
http.get(
endpoint,
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
),
);
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
).rejects.toThrow("Invalid import history response");
});
it("times out a stalled HTTP request without retries", async () => {
let calls = 0;
server.use(
http.get(endpoint, async () => {
calls++;
await delay(100);
return HttpResponse.json({ ok: true, jobs: [] });
}),
);
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
).rejects.toThrow("Import history request timed out");
expect(calls).toBe(1);
});
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
let entered = false;
let transportAborted = false;
server.use(
http.get(endpoint, async ({ request }) => {
request.signal.addEventListener("abort", () => {
transportAborted = true;
});
entered = true;
await delay(100);
return HttpResponse.json({ ok: true, jobs: [] });
}),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
const pending = cache.fetchQuery(options);
const rejection = expect(pending).rejects.toThrow();
await vi.waitFor(() => expect(entered).toBe(true));
await cache.cancelQueries({ queryKey: options.queryKey });
await rejection;
await vi.waitFor(() => expect(transportAborted).toBe(true));
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
});
});
describe("history refresh and mutation response integrity", () => {
it("retains the last valid page while a refresh fails", async () => {
server.use(
http.get(endpoint, () =>
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
),
);
const cache = client(),
options = furnitureJobsQueryOptions(false, null);
const observer = new QueryObserver(cache, { ...options, enabled: false });
const unsubscribe = observer.subscribe(() => {});
try {
await cache.fetchQuery(options);
server.use(
http.get(endpoint, () =>
HttpResponse.json({ error: "offline" }, { status: 500 }),
),
);
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
furnitureJobFixture.id,
);
expect(observer.getCurrentResult().error?.message).toBe("offline");
} finally {
unsubscribe();
}
});
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
server.use(
http.patch(endpoint, () =>
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
),
);
const response = await fetch(endpoint, { method: "PATCH" });
await expect(
readFurnitureJobResponse(response, "Update failed"),
).rejects.toThrow("Invalid import job response");
});
it("reports mutation HTTP failure without issuing a second write", async () => {
let calls = 0;
server.use(
http.post(endpoint, () => {
calls++;
return HttpResponse.json(
{ error: "Queue unavailable" },
{ status: 500 },
);
}),
);
const response = await fetch(endpoint, { method: "POST" });
await expect(
readFurnitureJobResponse(response, "Queue failed"),
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
expect(calls).toBe(1);
});
});
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
server.use(
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
);
onlineManager.setOnline(false);
try {
await expect(
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
).resolves.toEqual({ jobs: [], nextCursor: null });
} finally {
onlineManager.setOnline(true);
}
});
@@ -0,0 +1,164 @@
import { QueryClient, queryOptions } from "@tanstack/react-query";
import { z } from "zod/mini";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob } from "@/lib/furni/import-job";
const endpoint = "/api/admin/studio/import-jobs";
const historyTimeoutMs = 15_000;
const jobSchema = z.looseObject({
id: z.uuid(),
userId: z.int().check(z.positive()),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
translate: z.boolean(),
mode: z.optional(z.literal("repair")),
syncKind: z.optional(z.enum(["official", "clone"])),
operationId: z.optional(z.string()),
retryOf: z.optional(z.string()),
sourceId: z.optional(z.string()),
cancelRequested: z.optional(z.boolean()),
langs: z.optional(z.array(z.string())),
items: z.array(
z.looseObject({
id: z.int().check(z.nonnegative()),
classname: z.string(),
name: z.string(),
description: z.string(),
type: z.string(),
revision: z.number(),
category: z.string(),
state: z.enum([
"pending",
"running",
"done",
"failed",
"cancelled",
"interrupted",
]),
error: z.optional(z.string()),
warnings: z.optional(z.array(z.string())),
itemId: z.optional(z.number()),
}),
),
});
const historySchema = z.object({
ok: z.literal(true),
jobs: z.array(jobSchema).check(z.maxLength(30)),
nextCursor: z.optional(
z.nullable(
z.string().check(
z.regex(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
),
z.refine((value) => {
const [timestamp, id] = value.split("|");
return (
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
z.uuid().safeParse(id).success
);
}),
),
),
),
});
export interface FurnitureJobsPage {
jobs: ImportJob[];
nextCursor: string | null;
}
export class FurnitureJobsHttpError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function responseData(
response: Response,
fallback: string,
): Promise<unknown> {
const data: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message =
data &&
typeof data === "object" &&
"error" in data &&
typeof data.error === "string"
? data.error
: `${fallback} (${response.status})`;
throw new FurnitureJobsHttpError(message, response.status);
}
return data;
}
export async function readFurnitureJobResponse(
response: Response,
fallback: string,
): Promise<ImportJob> {
const data = await responseData(response, fallback);
const parsed = z
.object({ ok: z.literal(true), job: jobSchema })
.safeParse(data);
if (!parsed.success) throw Error("Invalid import job response");
return parsed.data.job as ImportJob;
}
/** Each mounted history owns this client; no persisted or cross-page account cache. */
export function createFurnitureJobsClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
networkMode: "always",
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
mutations: { retry: false },
},
});
}
export function furnitureJobsQueryOptions(
paginated: boolean,
cursor: string | null,
timeoutMs = historyTimeoutMs,
) {
return queryOptions({
queryKey: [
"furniture-import-history",
paginated,
paginated ? cursor : null,
] as const,
queryFn: async ({ signal }): Promise<FurnitureJobsPage> => {
const timeout = AbortSignal.timeout(timeoutMs);
const combined = AbortSignal.any([signal, timeout]);
const url = new URL(
endpoint,
typeof window === "undefined"
? "http://localhost"
: window.location.origin,
);
if (paginated) {
url.searchParams.set("page", "1");
if (cursor) url.searchParams.set("before", cursor);
}
try {
const response = await adminFetch(url, {
signal: combined,
cache: "no-store",
});
const data = await responseData(response, "Import history unavailable");
const parsed = historySchema.safeParse(data);
if (!parsed.success) throw Error("Invalid import history response");
return {
jobs: parsed.data.jobs as ImportJob[],
nextCursor: parsed.data.nextCursor ?? null,
};
} catch (error) {
if (timeout.aborted && !signal.aborted)
throw Error("Import history request timed out");
throw error;
}
},
});
}
@@ -4,6 +4,7 @@ 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 { ImportAttachmentRecovery } from "./import-attachment-recovery";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
{item.error}
</p>
)}
{!job.syncKind &&
item.state === "failed" &&
retryItems.some(
(retryItem) => retryItem.classname === item.classname,
) && (
<ImportAttachmentRecovery
jobId={job.id}
classname={item.classname}
disabled={state.busy}
onResumed={state.refresh}
/>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
@@ -0,0 +1,128 @@
"use client";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
export function ImportAttachmentRecovery({
jobId,
classname,
disabled,
onResumed,
}: {
jobId: string;
classname: string;
disabled: boolean;
onResumed: () => Promise<unknown>;
}) {
const t = useTranslations("admin.studio.importDiagnostics");
const lock = useRef(false);
const [busy, setBusy] = useState(false);
const [attachmentId, setAttachmentId] = useState("");
const [error, setError] = useState("");
async function attach(file: File) {
if (disabled || lock.current) return;
lock.current = true;
setBusy(true);
setError("");
setAttachmentId("");
try {
const form = new FormData();
form.set("classname", classname);
form.set("file", file);
const response = await adminFetch("/api/admin/studio/import-attachment", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok || typeof data.attachmentId !== "string") {
setError(
t(data.code === "mismatchedFile" ? "mismatchedFile" : "invalidFile"),
);
return;
}
setAttachmentId(data.attachmentId);
} catch {
setError(t("network"));
} finally {
lock.current = false;
setBusy(false);
}
}
async function resume() {
if (disabled || lock.current || !attachmentId) return;
lock.current = true;
setBusy(true);
setError("");
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: jobId,
action: "retry",
attachments: { [classname]: attachmentId },
}),
});
if (!response.ok) {
const data = await response.json();
const code =
data.code === "alreadyStarted"
? "alreadyStarted"
: data.code === "notEligible"
? "notEligible"
: data.code === "invalidFile"
? "invalidFile"
: "resumeError";
setError(t(code));
return;
}
setAttachmentId("");
await onResumed();
} catch {
setError(t("network"));
} finally {
lock.current = false;
setBusy(false);
}
}
return (
<details className="my-2 rounded border border-[var(--admin-border)] p-2">
<summary className="cursor-pointer">{t("recover")}</summary>
<p className="my-2 text-xs text-[var(--admin-text-muted)]">
{t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })}
</p>
<label className="block text-xs">
{t("attach")}
<input
type="file"
accept=".nitro"
className="my-2 block max-w-full"
disabled={disabled || busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void attach(file);
event.target.value = "";
}}
/>
</label>
{busy && <p role="status">{t("working")}</p>}
{attachmentId && (
<>
<p role="status">{t("attached")}</p>
<Button
variant="outline"
disabled={disabled || busy}
onClick={() => void resume()}
>
{t("resume")}
</Button>
</>
)}
{error && (
<p role="alert" className="text-[var(--admin-warning)]">
{error}
</p>
)}
</details>
);
}
+14 -3
View File
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import { FurnitureComparison } from "./furniture-inspector";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
import { useSourceAssetChecks } from "./use-source-asset-checks";
@@ -38,6 +39,7 @@ export function ImportReview({
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
@@ -57,7 +59,7 @@ export function ImportReview({
...current,
[item.classname]: data.attachmentId,
}));
toast.success("Matching .nitro attached to this import");
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
@@ -260,8 +262,8 @@ export function ImportReview({
{local.nitro.exists !== true && (
<label className="mt-3 block text-sm">
{attachments[item.classname]
? "Matching .nitro attached"
: "Attach the original .nitro"}
? diagnosticsT("attached")
: diagnosticsT("attach")}
<input
className="mt-2 block max-w-full"
type="file"
@@ -278,6 +280,15 @@ export function ImportReview({
)}
</label>
)}
{assets.items.find(
(check) => check.classname === item.classname,
) && (
<ImportSourceDiagnostics
check={assets.items.find(
(check) => check.classname === item.classname,
)}
/>
)}
<FurnitureComparison item={item} local={local} />
</div>
)}
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, args?: Record<string, unknown>) =>
`${key}${args?.status ?? ""}`,
}));
it("shows filenames, attempted addresses and distinct reasons inline", () => {
const html = renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
attempts: [
{
file: "chair.swf",
url: "https://source.test/1/chair.swf",
state: "missing",
reason: "http",
status: 404,
},
{
file: "chair.nitro",
url: "https://source.test/chair.nitro",
state: "unknown",
reason: "timeout",
},
],
}}
/>,
);
expect(html).toContain("chair.swf");
expect(html).toContain("http404");
expect(html).toContain("timeout");
expect(html).toContain("https://source.test/1/chair.swf");
expect(html).not.toContain("href=");
});
it("does not invent diagnostics for earlier responses", () => {
expect(
renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
}}
/>,
),
).toBe("");
});
@@ -0,0 +1,32 @@
"use client";
import { useTranslations } from "next-intl";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
export function ImportSourceDiagnostics({
check,
}: {
check?: SourceAssetCheck;
}) {
const t = useTranslations("admin.studio.importDiagnostics");
if (!check?.attempts?.length) return null;
return (
<details className="mt-2 text-xs">
<summary className="cursor-pointer">{t("attempts")}</summary>
<p className="mt-2 text-[var(--admin-text-muted)]">{t("preliminary")}</p>
<ul className="space-y-2 py-2">
{[
...new Map(
check.attempts.map((attempt) => [attempt.url, attempt]),
).values(),
].map((attempt) => (
<li key={attempt.url}>
<strong>{attempt.file}</strong> ·{" "}
{attempt.reason === "http"
? t("http", { status: attempt.status ?? 0 })
: t(attempt.reason)}
<code className="block break-all">{attempt.url}</code>
</li>
))}
</ul>
</details>
);
}
@@ -1,76 +1,87 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, 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";
import type { ImportJobItem } from "@/lib/furni/import-job";
import {
createFurnitureJobsClient,
FurnitureJobsHttpError,
type FurnitureJobsPage,
furnitureJobsQueryOptions,
readFurnitureJobResponse,
} from "./furniture-jobs-query";
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const t = useTranslations("pages.admin.importHistory");
const [client] = useState(createFurnitureJobsClient);
const cursor = useRef<string | null>(null);
const previous = useRef<Array<string | null>>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageCursor, setPageCursor] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const query = useQuery(
{ ...furnitureJobsQueryOptions(paginated, pageCursor), enabled: false },
client,
);
const unauthorized =
query.error instanceof FurnitureJobsHttpError &&
[401, 403].includes(query.error.status);
const jobs = unauthorized ? [] : (query.data?.jobs ?? []);
const nextCursor = unauthorized ? null : (query.data?.nextCursor ?? null);
const loading = query.isFetching;
const error = query.error?.message ?? "";
const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const completed = useRef(new Set<string>()),
callback = useRef(onComplete);
const [busy, setBusy] = useState(false);
const completed = useRef(new Set<string>());
const callback = useRef(onComplete);
callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null);
const submitting = useRef(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
setLoading(true);
try {
const response = await adminFetch(
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
);
const data = await response.json();
if (!response.ok)
throw Error(
data.error || `Import history unavailable (${response.status})`,
);
setJobs(data.jobs);
setNextCursor(data.nextCursor ?? null);
setError("");
let changed = false;
for (const job of data.jobs as ImportJob[])
if (
(job.state === "completed" ||
job.state === "interrupted" ||
job.state === "cancelled") &&
!completed.current.has(job.id)
) {
completed.current.add(job.id);
changed = true;
}
if (changed) callback.current();
} catch (error) {
setError(
error instanceof Error ? error.message : "Cannot load import history",
);
} finally {
refreshing.current = false;
setLoading(false);
useEffect(
() => () => {
client.clear();
},
[client],
);
useEffect(() => {
if (!query.data || unauthorized) return;
let changed = false;
for (const job of query.data.jobs) {
if (
["completed", "interrupted", "cancelled"].includes(job.state) &&
!completed.current.has(job.id)
) {
completed.current.add(job.id);
changed = true;
}
}
}, [paginated]);
if (changed) callback.current();
}, [query.data, unauthorized]);
const refresh = useCallback(async () => {
// fetchQuery shares an in-flight GET. Query owns data, errors and loading.
await client
.fetchQuery(furnitureJobsQueryOptions(paginated, cursor.current))
.catch(() => undefined);
}, [client, paginated]);
const resetPage = () => {
cursor.current = null;
previous.current = [];
setPageCursor(null);
setPage(1);
};
const navigate = async (direction: "next" | "previous") => {
if (refreshing.current) return;
if (client.isFetching()) return;
if (direction === "next" && nextCursor) {
previous.current.push(cursor.current);
cursor.current = nextCursor;
} else if (direction === "previous" && previous.current.length)
cursor.current = previous.current.pop() ?? null;
else return;
setPageCursor(cursor.current);
setPage(previous.current.length + 1);
setJobs([]);
setNextCursor(null);
await refresh();
};
const retry = async (id: string) => {
@@ -83,11 +94,9 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action: "retry" }),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("cancelFailed"));
cursor.current = null;
previous.current = [];
setPage(1);
await readFurnitureJobResponse(response, t("cancelFailed"));
await client.cancelQueries();
resetPage();
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
@@ -99,6 +108,7 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const active = jobs.some(
(job) => job.state === "queued" || job.state === "running",
);
// Retain settle-then-poll timing and visibility behavior without global focus state.
useVisiblePolling(refresh, active ? 5000 : 30000);
const cancel = async (id: string) => {
if (cancelInFlight.current) return;
@@ -110,10 +120,17 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
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)),
const job = await readFurnitureJobResponse(response, t("cancelFailed"));
await client.cancelQueries();
client.setQueriesData<FurnitureJobsPage>(
{ queryKey: ["furniture-import-history"] },
(current) =>
current
? {
...current,
jobs: current.jobs.map((item) => (item.id === id ? job : item)),
}
: current,
);
await refresh();
} catch (error) {
@@ -145,12 +162,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, id: request.current.id }),
});
const data = await response.json();
if (!response.ok)
throw Error(
data.error || `Could not queue import (${response.status})`,
);
await readFurnitureJobResponse(response, "Could not queue import");
request.current = null;
await client.cancelQueries();
resetPage();
await refresh();
toast.success(
"Import queued. You can leave this page and return to its history.",
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import {
decodeRecovery,
encodeRecovery,
type RecoveryKind,
recoveryKey,
} from "@/lib/form-recovery";
/** Local fallback complements server autosave and survives an expired session. */
export function useFormRecovery({
userId,
kind,
record,
dirty,
snapshot,
restore,
serverDraft,
}: {
userId?: string;
kind: RecoveryKind;
record: string;
dirty: boolean;
serverDraft?: unknown;
snapshot: () => unknown;
restore: (payload: Record<string, unknown>) => void;
}) {
const t = useTranslations("draftRecovery");
const key = userId ? recoveryKey(userId, kind, record) : null;
const current = useRef({ dirty, snapshot, restore, key });
current.current = { dirty, snapshot, restore, key };
const [found, setFound] = useState<ReturnType<typeof decodeRecovery> | null>(
null,
);
const [state, setState] = useState<"ready" | "saved" | "unavailable">(
"ready",
);
const blocked = useRef(true);
const last = useRef<string | null>(null);
const suppressed = useRef(false);
const lastPayload = useRef("");
const savedSnapshot = useRef("");
const flush = useRef(() => {});
useEffect(() => {
blocked.current = true;
suppressed.current = false;
lastPayload.current = "";
setFound(null);
setState("ready");
if (!key) return;
try {
const raw = localStorage.getItem(key);
last.current = raw;
if (raw) {
try {
setFound(decodeRecovery(kind, raw));
} catch {
localStorage.removeItem(key);
last.current = null;
blocked.current = false;
}
} else blocked.current = false;
} catch {
setState("unavailable");
blocked.current = false;
}
function persist() {
if (
current.current.key !== key ||
blocked.current ||
!current.current.dirty
)
return;
if (
suppressed.current &&
JSON.stringify(current.current.snapshot()) === savedSnapshot.current
)
return;
suppressed.current = false;
try {
// Do not overwrite a draft written by another tab without offering recovery.
const existing = localStorage.getItem(key as string);
if (existing !== last.current && existing) {
setFound(decodeRecovery(kind, existing));
last.current = existing;
blocked.current = true;
return;
}
const payload = encodeRecovery(kind, current.current.snapshot(), 0);
if (payload === lastPayload.current && existing === last.current)
return;
const raw = encodeRecovery(kind, current.current.snapshot());
localStorage.setItem(key as string, raw);
lastPayload.current = payload;
last.current = raw;
setState("saved");
} catch {
setState("unavailable");
}
}
flush.current = persist;
const timer = window.setInterval(persist, 750);
const visibility = () => {
if (document.visibilityState === "hidden") persist();
};
window.addEventListener("pagehide", persist);
window.addEventListener("beforeunload", persist);
document.addEventListener("visibilitychange", visibility);
return () => {
persist();
clearInterval(timer);
window.removeEventListener("pagehide", persist);
window.removeEventListener("beforeunload", persist);
document.removeEventListener("visibilitychange", visibility);
flush.current = () => {};
};
}, [key, kind]);
function clear() {
blocked.current = false;
lastPayload.current = "";
suppressed.current = true;
savedSnapshot.current = JSON.stringify(current.current.snapshot());
setFound(null);
if (!key) return;
try {
if (localStorage.getItem(key) === last.current)
localStorage.removeItem(key);
last.current = null;
setState("ready");
} catch {
setState("unavailable");
}
}
useEffect(() => {
if (!found || !serverDraft) return;
try {
if (
encodeRecovery(kind, found.payload, 0) ===
encodeRecovery(kind, serverDraft, 0)
) {
setFound(null);
blocked.current = false;
}
} catch {
/* The server keeps its own validation and recovery flow. */
}
}, [found, serverDraft, kind]);
// A new edit after successful save enables the next recovery snapshot.
function changed() {
suppressed.current = false;
}
return {
clear,
changed,
flush: () => flush.current(),
panel: !key ? null : (
<div className="grid gap-2 text-xs" aria-live="polite">
<p>
{t(found ? "found" : state)}
{found && (
<>
{" "}
<time dateTime={new Date(found.savedAt).toISOString()}>
{new Date(found.savedAt).toLocaleString()}
</time>
</>
)}
</p>
{found && (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!window.confirm(t("confirmRestore"))) return;
current.current.restore(found.payload);
blocked.current = false;
suppressed.current = false;
setFound(null);
}}
>
{t("restore")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!window.confirm(t("confirmDiscard"))) return;
clear();
blocked.current = false;
suppressed.current = false;
}}
>
{t("discard")}
</button>
</div>
)}
</div>
),
};
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
DRAFT_TTL,
decodeRecovery,
encodeRecovery,
recoveryKey,
} from "./form-recovery";
const article = {
title: "Draft",
slug: "draft",
image: "",
shortStory: "",
fullStory: "<p>Unsaved</p>",
status: "draft",
publishAt: "",
};
describe("form recovery storage contract", () => {
it("isolates account, form and record, including separator characters", () => {
expect(
new Set([
recoveryKey("1", "article", "new"),
recoveryKey("2", "article", "new"),
recoveryKey("1", "event", "new"),
recoveryKey("1", "article", "2"),
recoveryKey("1:article", "article", "new"),
]).size,
).toBe(5);
});
it("round trips unfinished content and only retains allowlisted fields", () => {
const raw = encodeRecovery(
"article",
{
...article,
password: "private",
accessToken: "secret",
baseToken: "version",
},
1000,
);
expect(decodeRecovery("article", raw, 1001).payload).toEqual(article);
expect(raw).not.toMatch(/private|secret|baseToken/);
});
it.each([
"{",
JSON.stringify({ version: 2, savedAt: 1000, payload: article }),
JSON.stringify({
version: 1,
savedAt: 1000,
payload: { ...article, title: 42 },
}),
])("rejects malformed or unsupported records", (raw) => {
expect(() => decodeRecovery("article", raw, 1001)).toThrow();
});
it("expires old records and rejects future timestamps", () => {
expect(() =>
decodeRecovery(
"article",
encodeRecovery("article", article, 1000),
1001 + DRAFT_TTL,
),
).toThrow();
expect(() =>
decodeRecovery(
"article",
encodeRecovery("article", article, 100000),
1000,
),
).toThrow();
});
it("limits content size", () => {
expect(() =>
encodeRecovery("article", { ...article, fullStory: "x".repeat(500001) }),
).toThrow();
});
it("preserves optional event dates without coercing absent dates", () => {
const event = {
title: "",
description: "",
typeId: 1,
status: "draft",
isRecurring: 0,
startsAt: "2030-01-01T12:00:00.000Z",
endsAt: null,
roomId: "",
};
expect(
decodeRecovery("event", encodeRecovery("event", event)).payload,
).toEqual(event);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { z } from "zod";
export const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000;
const text = z.string().max(500_000);
export const articleRecoverySchema = z.object({
title: z.string().max(255),
slug: z.string().max(255),
image: z.string().max(255),
shortStory: z.string().max(255),
fullStory: text,
status: z.enum(["draft", "scheduled", "published"]),
publishAt: z.string().max(255).default(""),
});
const optionalNumber = z
.union([z.string().max(30), z.number().finite(), z.null()])
.optional();
const date = z.union([z.string().datetime(), z.null()]).optional();
export const eventRecoverySchema = z.object({
title: z.string().max(255),
description: text,
image: z.string().max(500).nullable().optional(),
typeId: z.number().int().nonnegative(),
status: z.enum(["draft", "published", "cancelled", "completed"]),
isRecurring: z.number().int().min(0).max(1),
recurrenceRule: z.string().max(255).nullable().optional(),
maxPlayers: optionalNumber,
roomId: optionalNumber,
startsAt: date,
endsAt: date,
});
export type RecoveryKind = "article" | "event";
export function recoveryKey(
userId: string,
kind: RecoveryKind,
record: string,
) {
return `cms:draft:v1:${encodeURIComponent(userId)}:${kind}:${encodeURIComponent(record)}`;
}
export function validateRecovery(
kind: RecoveryKind,
value: unknown,
): Record<string, unknown> {
return (
kind === "article" ? articleRecoverySchema : eventRecoverySchema
).parse(value);
}
export function encodeRecovery(
kind: RecoveryKind,
value: unknown,
now = Date.now(),
) {
const raw = JSON.stringify({
version: 1,
savedAt: now,
payload: validateRecovery(kind, value),
});
if (raw.length > 600_000) throw new Error("draftTooLarge");
return raw;
}
export function decodeRecovery(
kind: RecoveryKind,
raw: string,
now = Date.now(),
) {
if (raw.length > 600_000) throw new Error("draftTooLarge");
const envelope = z
.object({
version: z.literal(1),
savedAt: z.number().int(),
payload: z.unknown(),
})
.parse(JSON.parse(raw));
if (envelope.savedAt > now + 60_000 || now - envelope.savedAt > DRAFT_TTL)
throw new Error("draftExpired");
return {
savedAt: envelope.savedAt,
payload: validateRecovery(kind, envelope.payload),
};
}
+8
View File
@@ -8,7 +8,15 @@ export interface SourceAssetItem {
category: string;
}
export type AssetAvailability = "available" | "missing" | "unknown";
export interface SourceAssetAttempt {
url: string;
file: string;
state: AssetAvailability;
reason: "available" | "http" | "network" | "timeout" | "invalidResponse";
status?: number;
}
export interface SourceAssetCheck {
attempts?: SourceAssetAttempt[];
classname: string;
state: AssetAvailability;
revision: number;
+7
View File
@@ -20,6 +20,7 @@ import {
getFurnitureDataWritePaths,
removeFurniEntry,
} from "@/lib/services/furni-data";
import { diagnosticAssetUrl } from "@/lib/services/furniture-source-assets";
import {
lookupOfficialHabboFurni,
type OfficialHabboFurniEntry,
@@ -707,6 +708,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
iconFailure = detail;
warnings.push(`Icon download failed: ${detail}`);
}),
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
? Promise.resolve(false)
@@ -714,6 +716,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
swfFailure = detail;
warnings.push(`SWF download failed: ${detail}`);
}),
]);
let iconOk = iconOkResult;
@@ -729,6 +732,10 @@ export async function importSingleFurni(params: {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true;
else
warnings.push(
`Nitro download failed: ${diagnosticAssetUrl(nitroUrl)}: ${dl.error ?? "unknown cause"}`,
);
}
// ── Icon fallback: extract from SWF if download failed ────────────
+41
View File
@@ -201,3 +201,44 @@ it("paginates timestamp ties without duplicates and excludes other owners", asyn
);
expect(first.jobs.length + second.jobs.length).toBe(3);
});
it("attaches recovery files only to safe failed items and preserves retry history", async () => {
const s = await store();
const original: ImportJob = {
...job(),
state: "completed",
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "failed",
},
{
id: 2,
classname: "lamp",
name: "Lamp",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "done",
},
],
};
await s.create(original);
await expect(s.retry(original.id, 4, { lamp: randomUUID() })).rejects.toThrow(
"not eligible",
);
const attachmentId = randomUUID();
const child = await s.retry(original.id, 4, { chair: attachmentId });
expect(child?.items).toHaveLength(1);
expect(child?.items[0].attachmentId).toBe(attachmentId);
expect((await s.read(original.id)).items[0].attachmentId).toBeUndefined();
await expect(
s.retry(original.id, 4, { chair: randomUUID() }),
).rejects.toThrow("latest retry");
});
+32 -2
View File
@@ -74,7 +74,11 @@ export class ImportJobStore {
});
return this.read(id);
}
async retry(id: string, userId: number): Promise<ImportJob | null> {
async retry(
id: string,
userId: number,
attachments: Record<string, string> = {},
): Promise<ImportJob | null> {
const original = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
@@ -82,11 +86,23 @@ export class ImportJobStore {
if (!original || original.userId !== userId) return null;
const items = retryableJobItems(original);
if (!items.length) return null;
const entries = Object.entries(attachments);
if (
entries.length &&
(original.syncKind ||
entries.some(
([classname]) =>
!items.some(
(item) => item.classname === classname && item.state === "failed",
),
))
)
throw Error("Furniture is not eligible for attachment recovery");
// A parent produces one durable child, including concurrent and uncertain responses.
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
const now = new Date().toISOString();
return this.create({
const child = await this.create({
...original,
id: retryId,
retryOf: id,
@@ -97,10 +113,24 @@ export class ImportJobStore {
items: items.map(
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
...item,
...(Object.hasOwn(attachments, item.classname)
? { attachmentId: attachments[item.classname] }
: {}),
state: "pending",
}),
),
});
if (
entries.some(
([classname, attachmentId]) =>
child.items.find((item) => item.classname === classname)
?.attachmentId !== attachmentId,
)
)
throw Error(
"Recovery already started. Use the latest retry in the history.",
);
return child;
}
async page(options: { userId: number; limit: number; before?: string }) {
const limit = Math.min(30, Math.max(1, options.limit));
@@ -74,3 +74,54 @@ describe("source asset preflight", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
const item = {
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 123,
category: "other",
};
it("reports every missing filename and HTTP reason without credentials", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
);
const result = await inspectSourceAssets(item, [], {
nitroBaseUrl: "https://user:[email protected]/private?token=secret",
} as never);
expect(result.state).toBe("missing");
expect(result.attempts).toHaveLength(2);
expect(result.attempts?.[1]).toMatchObject({
file: "chair.swf",
reason: "http",
status: 404,
});
expect(JSON.stringify(result)).not.toContain("secret");
});
it("keeps network and blocked sources unknown rather than calling them missing", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("https://secret.test/?token=secret")),
);
const result = await inspectSourceAssets({ ...item, revision: 124 }, []);
expect(result.state).toBe("unknown");
expect(result.attempts?.[0].reason).toBe("network");
expect(JSON.stringify(result)).not.toContain("secret");
});
it("distinguishes HTML responses from valid assets", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(null, { headers: { "content-type": "text/html" } }),
),
);
const result = await inspectSourceAssets({ ...item, revision: 125 }, []);
expect(result.attempts?.[0].reason).toBe("invalidResponse");
expect(result.state).toBe("unknown");
});
+49 -26
View File
@@ -1,35 +1,63 @@
import type {
AssetAvailability,
SourceAssetAttempt,
SourceAssetCheck,
SourceAssetItem,
} from "@/lib/furni/source-assets";
import type { CloneSource } from "./clone-sources";
import { browserHeaders } from "./import/core/browser-headers";
const cache = new Map<string, { expires: number; state: AssetAvailability }>();
export async function probeAsset(url: string): Promise<AssetAvailability> {
const cache = new Map<
string,
{ expires: number; attempt: SourceAssetAttempt }
>();
/** Only expose the address path; credentials, signed queries and fragments stay server-side. */
export function diagnosticAssetUrl(raw: string): string {
const url = new URL(raw);
return `${url.protocol}//${url.host}${url.pathname}`;
}
async function probeAssetAttempt(url: string): Promise<SourceAssetAttempt> {
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.state;
let state: AssetAvailability = "unknown";
if (cached && cached.expires > Date.now()) return cached.attempt;
const safeUrl = diagnosticAssetUrl(url);
const attempt: SourceAssetAttempt = {
url: safeUrl,
file: new URL(url).pathname.split("/").pop() || "",
state: "unknown",
reason: "network",
};
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(6000),
headers: browserHeaders(),
});
attempt.status = response.status;
if (
response.ok &&
!response.headers.get("content-type")?.includes("text/html")
)
state = "available";
else if (response.status === 404 || response.status === 410)
state = "missing";
} catch {
/* A network problem is not proof the asset is absent. */
) {
attempt.state = "available";
attempt.reason = "available";
} else if (response.ok) attempt.reason = "invalidResponse";
else {
attempt.reason = "http";
if (response.status === 404 || response.status === 410)
attempt.state = "missing";
}
} catch (error) {
attempt.reason =
error instanceof Error &&
(error.name === "TimeoutError" || error.name === "AbortError")
? "timeout"
: "network";
}
if (cache.size >= 2000) cache.clear();
cache.set(url, { expires: Date.now() + 60000, state });
return state;
cache.set(url, { expires: Date.now() + 60000, attempt });
return attempt;
}
export async function probeAsset(url: string): Promise<AssetAvailability> {
return (await probeAssetAttempt(url)).state;
}
export function furnitureAssetUrls(
item: SourceAssetItem,
@@ -51,19 +79,6 @@ export function furnitureAssetUrls(
);
return urls;
}
async function available(
item: SourceAssetItem,
source?: CloneSource | null,
): Promise<AssetAvailability> {
const states = await Promise.all(
furnitureAssetUrls(item, source).map(probeAsset),
);
return states.includes("available")
? "available"
: states.every((state) => state === "missing")
? "missing"
: "unknown";
}
export async function inspectSourceAssets(
item: SourceAssetItem,
official: SourceAssetItem[],
@@ -76,12 +91,20 @@ export async function inspectSourceAssets(
...item,
revision: item.revision || canonical?.revision || 0,
};
const state = await available(effective, source);
const attempts = await Promise.all(
furnitureAssetUrls(effective, source).map(probeAssetAttempt),
);
const state = attempts.some((attempt) => attempt.state === "available")
? "available"
: attempts.every((attempt) => attempt.state === "missing")
? "missing"
: "unknown";
return {
classname: item.classname,
revision: effective.revision,
state,
alternatives: [],
attempts,
};
}
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "ستُحفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
"saved": "تم حفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
"unavailable": "الاستعادة في المتصفح غير متاحة. أبقِ هذه الصفحة مفتوحة حتى يتم حفظ تغييراتك.",
"found": "توجد نسخة للاستعادة في هذا المتصفح، تم حفظها في",
"restore": "استعادة نسخة المتصفح",
"discard": "حذف نسخة المتصفح",
"confirmRestore": "هل تريد استبدال النموذج الحالي بنسخة الاستعادة من المتصفح؟ راجع المحتوى قبل الحفظ.",
"confirmDiscard": "هل تريد حذف نسخة الاستعادة هذه من المتصفح؟ سيبقى النموذج الحالي دون تغيير."
},
"nav": {},
"header": {},
"footer": {},
@@ -539,6 +549,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "تشخيص الملفات",
"preliminary": "فحص أولي للعناوين؛ سيتم التحقق من المحتوى عند الاستيراد.",
"http": "HTTP {status}",
"network": "تعذر الاتصال",
"timeout": "انتهت مهلة المصدر",
"invalidResponse": "أعاد المصدر صفحة ويب بدلاً من ملف",
"available": "تم العثور على الملف؛ ينتظر التحقق",
"recover": "إرفاق الأصل والاستئناف",
"recoveryHint": "اختر {file} (حتى 50 MB). تُفحص هويته. تُعاد العناصر الفاشلة فقط وتُحفظ الواردات المكتملة.",
"attach": "اختر ملف .nitro الأصلي",
"working": "جارٍ التحقق أو الاستئناف...",
"attached": "أُرفق الملف المطابق",
"resume": "استئناف العناصر الفاشلة بهذا الملف",
"invalidFile": "اختر ملف .nitro أصلياً مطابقاً، حتى 50 MB، ثم أعد المحاولة.",
"resumeError": "حدّث السجل واستخدم أحدث محاولة؛ تحقق من الملف وأعد المحاولة.",
"mismatchedFile": "هذا الملف يخص أثاثاً مختلفاً.",
"alreadyStarted": "بدأ الاستئناف بالفعل. حدّث السجل واستخدم أحدث محاولة.",
"notEligible": "هذا العنصر غير مؤهل للاسترداد بالمرفقات."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Копие за възстановяване ще се пази в този браузър 7 дни.",
"saved": "Копие за възстановяване е запазено в този браузър за 7 дни.",
"unavailable": "Възстановяването в браузъра не е достъпно. Остави тази страница отворена, докато промените бъдат запазени.",
"found": "В този браузър има копие за възстановяване, запазено на",
"restore": "Възстанови копието от браузъра",
"discard": "Изтрий копието от браузъра",
"confirmRestore": "Да се замени ли текущият формуляр с копието от браузъра? Провери съдържанието, преди да запазиш.",
"confirmDiscard": "Да се изтрие ли това копие от браузъра? Текущият формуляр ще остане непроменен."
},
"nav": {
"home": "Начало",
"community": "Общност",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Диагностика на файловете",
"preliminary": "Предварителна проверка на адресите; съдържанието се проверява при импортиране.",
"http": "HTTP {status}",
"network": "Неуспешна връзка",
"timeout": "Изтекло време за отговор",
"invalidResponse": "Източникът върна уеб страница вместо файл",
"available": "Файлът е намерен; предстои проверка",
"recover": "Прикачване на оригинала и продължаване",
"recoveryHint": "Изберете {file} (до 50 MB). Проверява се самоличността му. Повтарят се неуспешните елементи; завършените се запазват.",
"attach": "Изберете оригиналния .nitro файл",
"working": "Проверка или продължаване...",
"attached": "Съответстващият файл е прикачен",
"resume": "Повторете неуспешните импортирания с файла",
"invalidFile": "Изберете съответстващ оригинален .nitro файл до 50 MB и опитайте отново.",
"resumeError": "Обновете историята и използвайте последния опит; проверете файла и опитайте отново.",
"mismatchedFile": "Този файл принадлежи на друга мебел.",
"alreadyStarted": "Възстановяването вече е започнало. Обновете историята и използвайте последния опит.",
"notEligible": "Този елемент не допуска възстановяване с прикачен файл."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Kopie pro obnovení bude v tomto prohlížeči uchována po dobu 7 dnů.",
"saved": "Kopie pro obnovení byla v tomto prohlížeči uložena na 7 dnů.",
"unavailable": "Obnovení v prohlížeči není dostupné. Nech tuto stránku otevřenou, dokud se změny neuloží.",
"found": "V tomto prohlížeči je dostupná kopie pro obnovení, uložená",
"restore": "Obnovit kopii z prohlížeče",
"discard": "Smazat kopii z prohlížeče",
"confirmRestore": "Nahradit aktuální formulář kopií z prohlížeče? Před uložením zkontroluj obsah.",
"confirmDiscard": "Smazat tuto kopii z prohlížeče? Aktuální formulář zůstane beze změn."
},
"nav": {
"home": "Domů",
"community": "Společenství",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnostika souborů",
"preliminary": "Předběžná kontrola adres; obsah se ověří při importu.",
"http": "HTTP {status}",
"network": "Připojení selhalo",
"timeout": "Zdroj neodpověděl včas",
"invalidResponse": "Zdroj vrátil webovou stránku místo souboru",
"available": "Soubor nalezen; čeká na ověření",
"recover": "Přiložit originál a pokračovat",
"recoveryHint": "Vyberte {file} (do 50 MB). Ověří se jeho identita. Opakují se neúspěšné položky; dokončené importy zůstanou zachovány.",
"attach": "Vyberte původní soubor .nitro",
"working": "Ověřování nebo pokračování...",
"attached": "Odpovídající soubor přiložen",
"resume": "Opakovat neúspěšné importy s tímto souborem",
"invalidFile": "Vyberte odpovídající původní .nitro do 50 MB a zkuste to znovu.",
"resumeError": "Obnovte historii a použijte poslední pokus; zkontrolujte soubor a zkuste to znovu.",
"mismatchedFile": "Tento soubor patří jinému nábytku.",
"alreadyStarted": "Obnova již začala. Obnovte historii a použijte poslední pokus.",
"notEligible": "Tuto položku nelze obnovit přílohou."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "En gendannelseskopi gemmes i denne browser i 7 dage.",
"saved": "En gendannelseskopi er gemt i denne browser i 7 dage.",
"unavailable": "Gendannelse i browseren er ikke tilgængelig. Hold denne side åben, indtil dine ændringer er gemt.",
"found": "Der er en gendannelseskopi i denne browser, gemt den",
"restore": "Gendan browserkopi",
"discard": "Slet browserkopi",
"confirmRestore": "Erstat den aktuelle formular med browserens gendannelseskopi? Kontrollér indholdet, før du gemmer.",
"confirmDiscard": "Slet denne gendannelseskopi fra browseren? Den aktuelle formular forbliver uændret."
},
"nav": {
"home": "Hjem",
"community": "Fællesskab",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Fildiagnostik",
"preliminary": "Foreløbig kontrol af adresser; indholdet valideres under import.",
"http": "HTTP {status}",
"network": "Forbindelsen mislykkedes",
"timeout": "Kilden svarede ikke i tide",
"invalidResponse": "Kilden returnerede en webside i stedet for en fil",
"available": "Fil fundet; afventer validering",
"recover": "Vedhæft originalen og fortsæt",
"recoveryHint": "Vælg {file} (højst 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves igen; færdige importer bevares.",
"attach": "Vælg den originale .nitro-fil",
"working": "Validerer eller genoptager...",
"attached": "Matchende fil vedhæftet",
"resume": "Genoptag mislykkede importer med filen",
"invalidFile": "Vælg en matchende original .nitro-fil på højst 50 MB og prøv igen.",
"resumeError": "Opdater historikken og brug seneste forsøg; kontroller filen og prøv igen.",
"mismatchedFile": "Denne fil tilhører et andet møbel.",
"alreadyStarted": "Genoptagelsen er startet. Opdater historikken og brug seneste forsøg.",
"notEligible": "Dette element kan ikke gendannes med en vedhæftet fil."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Eine Wiederherstellungskopie wird 7 Tage in diesem Browser aufbewahrt.",
"saved": "Die Wiederherstellungskopie wurde für 7 Tage in diesem Browser gespeichert.",
"unavailable": "Die Wiederherstellung im Browser ist nicht verfügbar. Lass diese Seite geöffnet, bis deine Änderungen gespeichert sind.",
"found": "In diesem Browser ist eine Wiederherstellungskopie verfügbar, gespeichert am",
"restore": "Browserkopie wiederherstellen",
"discard": "Browserkopie löschen",
"confirmRestore": "Das aktuelle Formular durch die Wiederherstellungskopie im Browser ersetzen? Prüfe den Inhalt vor dem Speichern.",
"confirmDiscard": "Diese Wiederherstellungskopie im Browser löschen? Das aktuelle Formular bleibt unverändert."
},
"nav": {
"home": "Start",
"community": "Community",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Dateidiagnose",
"preliminary": "Vorprüfung der Adressen; der Inhalt wird beim Import geprüft.",
"http": "HTTP {status}",
"network": "Verbindung fehlgeschlagen",
"timeout": "Zeitüberschreitung der Quelle",
"invalidResponse": "Die Quelle lieferte eine Webseite statt einer Datei",
"available": "Datei gefunden; Prüfung steht aus",
"recover": "Original anhängen und fortsetzen",
"recoveryHint": "Wähle {file} (maximal 50 MB). Die Identität wird geprüft. Fehlgeschlagene Einträge werden wiederholt; abgeschlossene Importe bleiben erhalten.",
"attach": "Originale .nitro-Datei wählen",
"working": "Datei wird geprüft oder Import fortgesetzt...",
"attached": "Passende Originaldatei angehängt",
"resume": "Fehlgeschlagene Importe mit dieser Datei fortsetzen",
"invalidFile": "Wähle eine passende originale .nitro-Datei bis 50 MB und versuche es erneut.",
"resumeError": "Aktualisiere den Verlauf und nutze den neuesten Versuch; prüfe die Datei und versuche es erneut.",
"mismatchedFile": "Diese Datei gehört zu einem anderen Möbelstück.",
"alreadyStarted": "Die Wiederherstellung läuft bereits. Aktualisiere den Verlauf und nutze den neuesten Versuch.",
"notEligible": "Dieser Eintrag kann nicht mit einem Anhang wiederhergestellt werden."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Ένα αντίγραφο ανάκτησης θα διατηρηθεί σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
"saved": "Το αντίγραφο ανάκτησης αποθηκεύτηκε σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
"unavailable": "Η ανάκτηση στο πρόγραμμα περιήγησης δεν είναι διαθέσιμη. Κράτησε αυτή τη σελίδα ανοιχτή μέχρι να αποθηκευτούν οι αλλαγές σου.",
"found": "Υπάρχει αντίγραφο ανάκτησης σε αυτό το πρόγραμμα περιήγησης, αποθηκευμένο στις",
"restore": "Επαναφορά αντιγράφου περιήγησης",
"discard": "Διαγραφή αντιγράφου περιήγησης",
"confirmRestore": "Να αντικατασταθεί η τρέχουσα φόρμα με το αντίγραφο ανάκτησης του προγράμματος περιήγησης; Έλεγξε το περιεχόμενο πριν από την αποθήκευση.",
"confirmDiscard": "Να διαγραφεί αυτό το αντίγραφο ανάκτησης από το πρόγραμμα περιήγησης; Η τρέχουσα φόρμα θα παραμείνει αμετάβλητη."
},
"nav": {
"home": "Σπίτι",
"community": "Κοινότητα",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Διαγνωστικός έλεγχος αρχείων",
"preliminary": "Προκαταρκτικός έλεγχος διευθύνσεων· το περιεχόμενο ελέγχεται στην εισαγωγή.",
"http": "HTTP {status}",
"network": "Αποτυχία σύνδεσης",
"timeout": "Η πηγή δεν απάντησε εγκαίρως",
"invalidResponse": "Η πηγή επέστρεψε ιστοσελίδα αντί αρχείου",
"available": "Βρέθηκε αρχείο· εκκρεμεί έλεγχος",
"recover": "Επισύναψη πρωτοτύπου και συνέχεια",
"recoveryHint": "Επιλέξτε {file} (έως 50 MB). Ελέγχεται η ταυτότητά του. Επαναλαμβάνονται οι αποτυχίες· οι ολοκληρωμένες εισαγωγές διατηρούνται.",
"attach": "Επιλέξτε το αρχικό αρχείο .nitro",
"working": "Έλεγχος ή συνέχιση...",
"attached": "Επισυνάφθηκε αντίστοιχο αρχείο",
"resume": "Συνέχιση αποτυχημένων εισαγωγών με το αρχείο",
"invalidFile": "Επιλέξτε αντίστοιχο αρχικό .nitro έως 50 MB και δοκιμάστε ξανά.",
"resumeError": "Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια· ελέγξτε το αρχείο και ξαναδοκιμάστε.",
"mismatchedFile": "Αυτό το αρχείο ανήκει σε άλλο έπιπλο.",
"alreadyStarted": "Η ανάκτηση ξεκίνησε ήδη. Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια.",
"notEligible": "Αυτό το στοιχείο δεν επιτρέπει ανάκτηση με συνημμένο."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "A recovery copy will be kept in this browser for 7 days.",
"saved": "Recovery copy saved in this browser for 7 days.",
"unavailable": "Browser recovery is unavailable. Keep this page open until your changes are saved.",
"found": "A recovery copy is available in this browser, saved on",
"restore": "Restore browser copy",
"discard": "Discard browser copy",
"confirmRestore": "Replace the current form with the browser recovery copy? Review the contents before saving.",
"confirmDiscard": "Delete this browser recovery copy? Your current form will remain unchanged."
},
"nav": {
"home": "Home",
"community": "Community",
@@ -4661,6 +4671,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Source file diagnostics",
"preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.",
"http": "HTTP {status}",
"network": "Network connection failed",
"timeout": "The source did not respond in time",
"invalidResponse": "The source returned a web page instead of a file",
"available": "File found; content will be verified during import",
"recover": "Attach original file and resume",
"recoveryHint": "Choose {file} for this furniture (maximum 50 MB). Its identity is checked before use. Resuming retries the failed items and preserves completed imports.",
"attach": "Choose the original .nitro file",
"working": "Validating file or resuming import...",
"attached": "Matching original file attached",
"resume": "Resume failed imports with this file",
"invalidFile": "Could not attach the file. Select a valid original .nitro for this furniture, up to 50 MB, and try again.",
"resumeError": "Could not resume. Refresh the history and use the latest retry; check the original file and try again.",
"mismatchedFile": "This file belongs to different furniture.",
"alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.",
"notEligible": "This item is not eligible for attachment recovery."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Se conservará una copia de recuperación en este navegador durante 7 días.",
"saved": "Copia de recuperación guardada en este navegador durante 7 días.",
"unavailable": "La recuperación en el navegador no está disponible. Mantén esta página abierta hasta que se guarden los cambios.",
"found": "Hay una copia de recuperación disponible en este navegador, guardada el",
"restore": "Restaurar copia del navegador",
"discard": "Eliminar copia del navegador",
"confirmRestore": "¿Reemplazar el formulario actual con la copia de recuperación del navegador? Revisa el contenido antes de guardar.",
"confirmDiscard": "¿Eliminar esta copia de recuperación del navegador? El formulario actual no cambiará."
},
"nav": {
"home": "Inicio",
"community": "Comunidad",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnóstico de archivos",
"preliminary": "Comprobación preliminar de direcciones; el contenido se valida al importar.",
"http": "HTTP {status}",
"network": "Falló la conexión",
"timeout": "La fuente no respondió a tiempo",
"invalidResponse": "La fuente devolvió una página web en lugar de un archivo",
"available": "Archivo encontrado; pendiente de validación",
"recover": "Adjuntar original y reanudar",
"recoveryHint": "Elige {file} (máximo 50 MB). Se verifica su identidad. Se reintentan los elementos fallidos y se conservan los importados.",
"attach": "Elige el archivo .nitro original",
"working": "Validando o reanudando...",
"attached": "Archivo original correspondiente adjuntado",
"resume": "Reanudar importaciones fallidas con este archivo",
"invalidFile": "Elige un .nitro original correspondiente de hasta 50 MB y vuelve a intentarlo.",
"resumeError": "Actualiza el historial y usa el último intento; comprueba el archivo y vuelve a intentarlo.",
"mismatchedFile": "Este archivo pertenece a otro mueble.",
"alreadyStarted": "La recuperación ya comenzó. Actualiza el historial y usa el último intento.",
"notEligible": "Este elemento no admite recuperación mediante archivo adjunto."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Palautuskopio säilytetään tässä selaimessa 7 päivän ajan.",
"saved": "Palautuskopio on tallennettu tähän selaimeen 7 päiväksi.",
"unavailable": "Selaimen palautustoiminto ei ole käytettävissä. Pidä tämä sivu auki, kunnes muutoksesi on tallennettu.",
"found": "Tässä selaimessa on palautuskopio, joka on tallennettu",
"restore": "Palauta selaimen kopio",
"discard": "Poista selaimen kopio",
"confirmRestore": "Korvataanko nykyinen lomake selaimen palautuskopiolla? Tarkista sisältö ennen tallentamista.",
"confirmDiscard": "Poistetaanko tämä palautuskopio selaimesta? Nykyinen lomake säilyy ennallaan."
},
"nav": {},
"header": {},
"footer": {},
@@ -539,6 +549,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Tiedostojen diagnostiikka",
"preliminary": "Osoitteiden alustava tarkistus; sisältö tarkistetaan tuonnissa.",
"http": "HTTP {status}",
"network": "Yhteys epäonnistui",
"timeout": "Lähde ei vastannut ajoissa",
"invalidResponse": "Lähde palautti verkkosivun tiedoston sijaan",
"available": "Tiedosto löytyi; odottaa tarkistusta",
"recover": "Liitä alkuperäinen ja jatka",
"recoveryHint": "Valitse {file} (enintään 50 MB). Sen tunniste tarkistetaan. Epäonnistuneet kohteet yritetään uudelleen; valmiit tuonnit säilytetään.",
"attach": "Valitse alkuperäinen .nitro-tiedosto",
"working": "Tarkistetaan tai jatketaan...",
"attached": "Vastaava tiedosto liitetty",
"resume": "Jatka epäonnistuneita tuonteja tällä tiedostolla",
"invalidFile": "Valitse vastaava alkuperäinen .nitro, enintään 50 MB, ja yritä uudelleen.",
"resumeError": "Päivitä historia ja käytä uusinta yritystä; tarkista tiedosto ja yritä uudelleen.",
"mismatchedFile": "Tämä tiedosto kuuluu toiselle huonekalulle.",
"alreadyStarted": "Palautus on jo alkanut. Päivitä historia ja käytä uusinta yritystä.",
"notEligible": "Tätä kohdetta ei voi palauttaa liitteellä."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Une copie de récupération sera conservée dans ce navigateur pendant 7 jours.",
"saved": "Une copie de récupération a été enregistrée dans ce navigateur pour 7 jours.",
"unavailable": "La récupération dans le navigateur est indisponible. Garde cette page ouverte jusqu’à l’enregistrement de tes modifications.",
"found": "Une copie de récupération est disponible dans ce navigateur, enregistrée le",
"restore": "Restaurer la copie du navigateur",
"discard": "Supprimer la copie du navigateur",
"confirmRestore": "Remplacer le formulaire actuel par la copie de récupération du navigateur ? Vérifie le contenu avant d’enregistrer.",
"confirmDiscard": "Supprimer cette copie de récupération du navigateur ? Le formulaire actuel restera inchangé."
},
"nav": {
"home": "Accueil",
"community": "Communauté",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnostic des fichiers",
"preliminary": "Vérification préliminaire des adresses ; le contenu sera validé à l’importation.",
"http": "HTTP {status}",
"network": "Échec de la connexion",
"timeout": "La source n’a pas répondu à temps",
"invalidResponse": "La source a renvoyé une page web au lieu d’un fichier",
"available": "Fichier trouvé ; validation en attente",
"recover": "Joindre l’original et reprendre",
"recoveryHint": "Choisissez {file} (50 MB maximum). Son identité est vérifiée. Les éléments échoués sont relancés ; les imports terminés sont conservés.",
"attach": "Choisir le fichier .nitro original",
"working": "Validation ou reprise en cours...",
"attached": "Fichier original correspondant joint",
"resume": "Reprendre les imports échoués avec ce fichier",
"invalidFile": "Choisissez un .nitro original correspondant de 50 MB maximum et réessayez.",
"resumeError": "Actualisez l’historique et utilisez la dernière tentative ; vérifiez le fichier et réessayez.",
"mismatchedFile": "Ce fichier appartient à un autre meuble.",
"alreadyStarted": "La reprise a déjà commencé. Actualisez l’historique et utilisez la dernière tentative.",
"notEligible": "Cet élément ne permet pas la récupération par pièce jointe."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Kopija za oporavak čuvat će se u ovom pregledniku 7 dana.",
"saved": "Kopija za oporavak spremljena je u ovom pregledniku na 7 dana.",
"unavailable": "Oporavak u pregledniku nije dostupan. Ostavi ovu stranicu otvorenom dok se promjene ne spreme.",
"found": "U ovom pregledniku dostupna je kopija za oporavak, spremljena",
"restore": "Vrati kopiju iz preglednika",
"discard": "Izbriši kopiju iz preglednika",
"confirmRestore": "Zamijeniti trenutačni obrazac kopijom iz preglednika? Provjeri sadržaj prije spremanja.",
"confirmDiscard": "Izbrisati ovu kopiju iz preglednika? Trenutačni obrazac ostat će nepromijenjen."
},
"nav": {
"home": "Dom",
"community": "zajednica",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Dijagnostika datoteka",
"preliminary": "Preliminarna provjera adresa; sadržaj se provjerava pri uvozu.",
"http": "HTTP {status}",
"network": "Povezivanje nije uspjelo",
"timeout": "Izvor nije odgovorio na vrijeme",
"invalidResponse": "Izvor je vratio web-stranicu umjesto datoteke",
"available": "Datoteka pronađena; čeka provjeru",
"recover": "Priloži izvornik i nastavi",
"recoveryHint": "Odaberite {file} (do 50 MB). Provjerava se identitet. Ponavljaju se neuspjele stavke; dovršeni uvozi ostaju sačuvani.",
"attach": "Odaberite izvornu .nitro datoteku",
"working": "Provjera ili nastavak...",
"attached": "Odgovarajuća datoteka priložena",
"resume": "Nastavi neuspjele uvoze s datotekom",
"invalidFile": "Odaberite odgovarajuću izvornu .nitro datoteku do 50 MB i pokušajte ponovno.",
"resumeError": "Osvježite povijest i koristite posljednji pokušaj; provjerite datoteku i pokušajte ponovno.",
"mismatchedFile": "Ova datoteka pripada drugom komadu namještaja.",
"alreadyStarted": "Oporavak je već pokrenut. Osvježite povijest i koristite posljednji pokušaj.",
"notEligible": "Ova stavka ne podržava oporavak prilogom."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "A helyreállítási másolat 7 napig megmarad ebben a böngészőben.",
"saved": "A helyreállítási másolat 7 napra elmentve ebben a böngészőben.",
"unavailable": "A böngészőből történő helyreállítás nem érhető el. Tartsd nyitva ezt az oldalt a módosítások mentéséig.",
"found": "Ebben a böngészőben elérhető egy helyreállítási másolat. Mentés ideje:",
"restore": "Böngészőmásolat visszaállítása",
"discard": "Böngészőmásolat törlése",
"confirmRestore": "Lecseréled a jelenlegi űrlapot a böngészőben tárolt helyreállítási másolatra? Mentés előtt ellenőrizd a tartalmát.",
"confirmDiscard": "Törlöd ezt a helyreállítási másolatot a böngészőből? A jelenlegi űrlap változatlan marad."
},
"nav": {
"home": "Otthon",
"community": "közösség",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Fájldiagnosztika",
"preliminary": "A címek előzetes ellenőrzése; a tartalmat az importálás ellenőrzi.",
"http": "HTTP {status}",
"network": "Sikertelen kapcsolódás",
"timeout": "A forrás nem válaszolt időben",
"invalidResponse": "A forrás fájl helyett weboldalt adott vissza",
"available": "Fájl megtalálva; ellenőrzésre vár",
"recover": "Eredeti csatolása és folytatás",
"recoveryHint": "Válassza ki: {file} (legfeljebb 50 MB). Az azonosságát ellenőrizzük. A sikertelen elemek újrapróbálhatók; a kész importok megmaradnak.",
"attach": "Válassza ki az eredeti .nitro fájlt",
"working": "Ellenőrzés vagy folytatás...",
"attached": "Megfelelő eredeti fájl csatolva",
"resume": "Sikertelen importok folytatása ezzel a fájllal",
"invalidFile": "Válasszon megfelelő eredeti .nitro fájlt legfeljebb 50 MB méretben, és próbálja újra.",
"resumeError": "Frissítse az előzményeket, és használja a legutóbbi próbálkozást; ellenőrizze a fájlt, és próbálja újra.",
"mismatchedFile": "Ez a fájl másik bútorhoz tartozik.",
"alreadyStarted": "A helyreállítás már elindult. Frissítse az előzményeket, és használja a legutóbbi próbálkozást.",
"notEligible": "Ez az elem nem állítható helyre csatolmánnyal."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Una copia di recupero sarà conservata in questo browser per 7 giorni.",
"saved": "Copia di recupero salvata in questo browser per 7 giorni.",
"unavailable": "Recupero nel browser non disponibile. Tieni aperta questa pagina fino al salvataggio delle modifiche.",
"found": "È disponibile una copia di recupero in questo browser, salvata il",
"restore": "Ripristina copia del browser",
"discard": "Elimina copia del browser",
"confirmRestore": "Sostituire il modulo attuale con la copia di recupero del browser? Controlla il contenuto prima di salvare.",
"confirmDiscard": "Eliminare questa copia di recupero del browser? Il modulo attuale resterà invariato."
},
"nav": {
"home": "Home",
"community": "Community",
@@ -4666,6 +4676,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnostica file sorgente",
"preliminary": "Controllo preliminare: questi indirizzi sono stati verificati senza scaricare i file. L'importazione validerà i file originali.",
"http": "HTTP {status}",
"network": "Connessione alla sorgente non riuscita",
"timeout": "La sorgente non ha risposto in tempo",
"invalidResponse": "La sorgente ha restituito una pagina web invece di un file",
"available": "File trovato; il contenuto sarà verificato durante l'importazione",
"recover": "Carica il file originale e riprendi",
"recoveryHint": "Scegli {file} per questo furni (massimo 50 MB). L'identità viene verificata prima dell'uso. La ripresa ritenta gli elementi falliti e preserva le importazioni completate.",
"attach": "Scegli il file .nitro originale",
"working": "Validazione del file o ripresa dell'importazione...",
"attached": "File originale corrispondente allegato",
"resume": "Riprendi gli import falliti con questo file",
"invalidFile": "Impossibile allegare il file. Scegli un .nitro originale valido per questo furni, fino a 50 MB, e riprova.",
"resumeError": "Impossibile riprendere. Aggiorna la cronologia e usa l'ultimo tentativo; controlla il file originale e riprova.",
"mismatchedFile": "Questo file appartiene a un altro furni.",
"alreadyStarted": "Ripresa già avviata. Aggiorna la cronologia e usa l’ultimo tentativo.",
"notEligible": "Questo elemento non consente il recupero tramite allegato."
},
"completeness": {
"title": "Completezza e prossimi passi",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "復元用のコピーをこのブラウザーに7日間保存します。",
"saved": "復元用のコピーをこのブラウザーに7日間保存しました。",
"unavailable": "ブラウザーでの復元を利用できません。変更が保存されるまで、このページを開いたままにしてください。",
"found": "このブラウザーに復元用のコピーがあります。保存日時:",
"restore": "ブラウザーのコピーを復元",
"discard": "ブラウザーのコピーを削除",
"confirmRestore": "現在のフォームをブラウザー内の復元用コピーで置き換えますか?保存する前に内容を確認してください。",
"confirmDiscard": "このブラウザー内の復元用コピーを削除しますか?現在のフォームは変更されません。"
},
"nav": {},
"header": {},
"footer": {},
@@ -539,6 +549,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "ファイル診断",
"preliminary": "アドレスの事前確認です。内容はインポート時に検証します。",
"http": "HTTP {status}",
"network": "接続に失敗しました",
"timeout": "ソースの応答がタイムアウトしました",
"invalidResponse": "ソースがファイルではなくウェブページを返しました",
"available": "ファイルを検出しました。検証待ちです",
"recover": "元のファイルを添付して再開",
"recoveryHint": "{file}(最大50 MB)を選択してください。一致を確認し、失敗した項目を再試行します。完了したインポートは保持されます。",
"attach": "元の .nitro ファイルを選択",
"working": "検証または再開中...",
"attached": "一致する元のファイルを添付しました",
"resume": "このファイルで失敗したインポートを再開",
"invalidFile": "対応する元の .nitro ファイル(最大50 MB)を選択して再試行してください。",
"resumeError": "履歴を更新し、最新の試行を使用してください。ファイルを確認して再試行してください。",
"mismatchedFile": "このファイルは別の家具のものです。",
"alreadyStarted": "再開は既に開始されています。履歴を更新し、最新の試行を使用してください。",
"notEligible": "この項目は添付ファイルで復旧できません。"
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Een herstelkopie wordt 7 dagen in deze browser bewaard.",
"saved": "De herstelkopie is voor 7 dagen in deze browser opgeslagen.",
"unavailable": "Herstel via de browser is niet beschikbaar. Houd deze pagina open totdat je wijzigingen zijn opgeslagen.",
"found": "Er is een herstelkopie beschikbaar in deze browser, opgeslagen op",
"restore": "Browserkopie herstellen",
"discard": "Browserkopie verwijderen",
"confirmRestore": "Het huidige formulier vervangen door de herstelkopie uit de browser? Controleer de inhoud voordat je opslaat.",
"confirmDiscard": "Deze herstelkopie uit de browser verwijderen? Het huidige formulier blijft ongewijzigd."
},
"nav": {
"home": "Home",
"community": "Community",
@@ -4137,6 +4147,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Bestandsdiagnostiek",
"preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.",
"http": "HTTP {status}",
"network": "Verbinding mislukt",
"timeout": "De bron antwoordde niet op tijd",
"invalidResponse": "De bron gaf een webpagina in plaats van een bestand",
"available": "Bestand gevonden; wacht op validatie",
"recover": "Origineel toevoegen en hervatten",
"recoveryHint": "Kies {file} (maximaal 50 MB). De identiteit wordt gecontroleerd. Mislukte items worden opnieuw geprobeerd; voltooide imports blijven behouden.",
"attach": "Kies het originele .nitro-bestand",
"working": "Valideren of hervatten...",
"attached": "Overeenkomend origineel toegevoegd",
"resume": "Mislukte imports hervatten met dit bestand",
"invalidFile": "Kies een overeenkomend origineel .nitro-bestand van maximaal 50 MB en probeer opnieuw.",
"resumeError": "Vernieuw de geschiedenis en gebruik de laatste poging; controleer het bestand en probeer opnieuw.",
"mismatchedFile": "Dit bestand hoort bij een ander meubel.",
"alreadyStarted": "Herstel is al gestart. Vernieuw de geschiedenis en gebruik de laatste poging.",
"notEligible": "Dit item kan niet met een bijlage worden hersteld."
},
"completeness": {
"title": "Volledigheid en vervolgstappen",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "En gjenopprettingskopi lagres i denne nettleseren i 7 dager.",
"saved": "En gjenopprettingskopi er lagret i denne nettleseren i 7 dager.",
"unavailable": "Gjenoppretting i nettleseren er ikke tilgjengelig. Hold denne siden åpen til endringene dine er lagret.",
"found": "En gjenopprettingskopi er tilgjengelig i denne nettleseren, lagret",
"restore": "Gjenopprett nettleserkopi",
"discard": "Slett nettleserkopi",
"confirmRestore": "Erstatte det gjeldende skjemaet med gjenopprettingskopien fra nettleseren? Kontroller innholdet før du lagrer.",
"confirmDiscard": "Slette denne gjenopprettingskopien fra nettleseren? Det gjeldende skjemaet forblir uendret."
},
"nav": {
"home": "Hjem",
"community": "Fellesskap",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Fildiagnostikk",
"preliminary": "Foreløpig kontroll av adresser; innholdet valideres ved import.",
"http": "HTTP {status}",
"network": "Tilkoblingen mislyktes",
"timeout": "Kilden svarte ikke i tide",
"invalidResponse": "Kilden returnerte en nettside i stedet for en fil",
"available": "Fil funnet; venter på validering",
"recover": "Legg ved originalen og fortsett",
"recoveryHint": "Velg {file} (maksimalt 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves på nytt; fullførte importer bevares.",
"attach": "Velg den originale .nitro-filen",
"working": "Validerer eller gjenopptar...",
"attached": "Tilsvarende originalfil vedlagt",
"resume": "Gjenoppta mislykkede importer med filen",
"invalidFile": "Velg en tilsvarende original .nitro-fil på opptil 50 MB og prøv igjen.",
"resumeError": "Oppdater historikken og bruk siste forsøk; kontroller filen og prøv igjen.",
"mismatchedFile": "Denne filen tilhører et annet møbel.",
"alreadyStarted": "Gjenopprettingen har startet. Oppdater historikken og bruk siste forsøk.",
"notEligible": "Dette elementet kan ikke gjenopprettes med et vedlegg."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Kopia do odzyskania będzie przechowywana w tej przeglądarce przez 7 dni.",
"saved": "Kopia do odzyskania została zapisana w tej przeglądarce na 7 dni.",
"unavailable": "Odzyskiwanie w przeglądarce jest niedostępne. Nie zamykaj tej strony, dopóki zmiany nie zostaną zapisane.",
"found": "W tej przeglądarce jest dostępna kopia do odzyskania, zapisana",
"restore": "Przywróć kopię z przeglądarki",
"discard": "Usuń kopię z przeglądarki",
"confirmRestore": "Zastąpić bieżący formularz kopią z przeglądarki? Sprawdź zawartość przed zapisaniem.",
"confirmDiscard": "Usunąć tę kopię z przeglądarki? Bieżący formularz pozostanie bez zmian."
},
"nav": {
"home": "Dom",
"community": "Społeczność",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnostyka plików",
"preliminary": "Wstępna kontrola adresów; zawartość zostanie sprawdzona podczas importu.",
"http": "HTTP {status}",
"network": "Połączenie nie powiodło się",
"timeout": "Źródło nie odpowiedziało na czas",
"invalidResponse": "Źródło zwróciło stronę zamiast pliku",
"available": "Plik znaleziony; oczekuje na weryfikację",
"recover": "Dołącz oryginał i wznów",
"recoveryHint": "Wybierz {file} (do 50 MB). Tożsamość pliku jest sprawdzana. Ponawiane są nieudane pozycje; ukończone importy zostają zachowane.",
"attach": "Wybierz oryginalny plik .nitro",
"working": "Sprawdzanie lub wznawianie...",
"attached": "Dołączono pasujący oryginał",
"resume": "Wznów nieudane importy z tym plikiem",
"invalidFile": "Wybierz pasujący oryginalny plik .nitro do 50 MB i spróbuj ponownie.",
"resumeError": "Odśwież historię i użyj ostatniej próby; sprawdź plik i spróbuj ponownie.",
"mismatchedFile": "Ten plik należy do innego mebla.",
"alreadyStarted": "Odzyskiwanie już rozpoczęto. Odśwież historię i użyj ostatniej próby.",
"notEligible": "Tej pozycji nie można odzyskać za pomocą załącznika."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Uma cópia de recuperação será mantida neste navegador durante 7 dias.",
"saved": "Cópia de recuperação guardada neste navegador durante 7 dias.",
"unavailable": "A recuperação no navegador não está disponível. Mantém esta página aberta até que as alterações sejam guardadas.",
"found": "Existe uma cópia de recuperação neste navegador, guardada em",
"restore": "Restaurar cópia do navegador",
"discard": "Eliminar cópia do navegador",
"confirmRestore": "Substituir o formulário atual pela cópia de recuperação do navegador? Verifica o conteúdo antes de guardar.",
"confirmDiscard": "Eliminar esta cópia de recuperação do navegador? O formulário atual permanecerá inalterado."
},
"nav": {
"home": "Página inicial",
"community": "Comunidade",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnóstico dos ficheiros",
"preliminary": "Verificação preliminar dos endereços; o conteúdo será validado na importação.",
"http": "HTTP {status}",
"network": "Falha na ligação",
"timeout": "A fonte não respondeu a tempo",
"invalidResponse": "A fonte devolveu uma página em vez de um ficheiro",
"available": "Ficheiro encontrado; aguarda validação",
"recover": "Anexar original e retomar",
"recoveryHint": "Escolha {file} (até 50 MB). A identidade é verificada. Os itens com falha são repetidos; as importações concluídas são preservadas.",
"attach": "Escolher o ficheiro .nitro original",
"working": "A validar ou retomar...",
"attached": "Original correspondente anexado",
"resume": "Retomar importações com falha usando este ficheiro",
"invalidFile": "Escolha um .nitro original correspondente de até 50 MB e tente novamente.",
"resumeError": "Atualize o histórico e use a última tentativa; verifique o ficheiro e tente novamente.",
"mismatchedFile": "Este ficheiro pertence a outro móvel.",
"alreadyStarted": "A recuperação já começou. Atualize o histórico e use a última tentativa.",
"notEligible": "Este item não permite recuperação por anexo."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "O copie de recuperare va fi păstrată în acest browser timp de 7 zile.",
"saved": "Copia de recuperare a fost salvată în acest browser pentru 7 zile.",
"unavailable": "Recuperarea în browser nu este disponibilă. Păstrează această pagină deschisă până când modificările sunt salvate.",
"found": "În acest browser este disponibilă o copie de recuperare, salvată la",
"restore": "Restaurează copia din browser",
"discard": "Șterge copia din browser",
"confirmRestore": "Înlocuiești formularul actual cu copia de recuperare din browser? Verifică conținutul înainte de salvare.",
"confirmDiscard": "Ștergi această copie de recuperare din browser? Formularul actual va rămâne neschimbat."
},
"nav": {
"home": "Acasă",
"community": "comunitate",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnosticarea fișierelor",
"preliminary": "Verificare preliminară a adreselor; conținutul se validează la import.",
"http": "HTTP {status}",
"network": "Conexiunea a eșuat",
"timeout": "Sursa nu a răspuns la timp",
"invalidResponse": "Sursa a returnat o pagină web în loc de fișier",
"available": "Fișier găsit; așteaptă validarea",
"recover": "Atașează originalul și reia",
"recoveryHint": "Alege {file} (maximum 50 MB). Identitatea se verifică. Elementele eșuate se reîncearcă; importurile finalizate se păstrează.",
"attach": "Alege fișierul .nitro original",
"working": "Validare sau reluare...",
"attached": "Fișierul original corespunzător a fost atașat",
"resume": "Reia importurile eșuate cu acest fișier",
"invalidFile": "Alege un .nitro original corespunzător de maximum 50 MB și încearcă din nou.",
"resumeError": "Actualizează istoricul și folosește ultima încercare; verifică fișierul și încearcă din nou.",
"mismatchedFile": "Acest fișier aparține altui obiect de mobilier.",
"alreadyStarted": "Recuperarea a început deja. Actualizează istoricul și folosește ultima încercare.",
"notEligible": "Acest element nu permite recuperarea prin atașament."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Копия для восстановления будет храниться в этом браузере 7 дней.",
"saved": "Копия для восстановления сохранена в этом браузере на 7 дней.",
"unavailable": "Восстановление в браузере недоступно. Не закрывай эту страницу, пока изменения не будут сохранены.",
"found": "В этом браузере доступна копия для восстановления. Дата сохранения:",
"restore": "Восстановить копию из браузера",
"discard": "Удалить копию из браузера",
"confirmRestore": "Заменить текущую форму копией из браузера? Проверь содержимое перед сохранением.",
"confirmDiscard": "Удалить эту копию из браузера? Текущая форма останется без изменений."
},
"nav": {
"home": "Главная",
"community": "Сообщество",
@@ -3782,6 +3792,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Диагностика файлов",
"preliminary": "Предварительная проверка адресов; содержимое проверяется при импорте.",
"http": "HTTP {status}",
"network": "Не удалось подключиться",
"timeout": "Источник не ответил вовремя",
"invalidResponse": "Источник вернул веб-страницу вместо файла",
"available": "Файл найден; ожидает проверки",
"recover": "Прикрепить оригинал и продолжить",
"recoveryHint": "Выберите {file} (до 50 MB). Подлинность соответствия проверяется. Повторяются неудачные элементы; завершённые импорты сохраняются.",
"attach": "Выберите исходный файл .nitro",
"working": "Проверка или возобновление...",
"attached": "Соответствующий оригинал прикреплён",
"resume": "Повторить неудачные импорты с этим файлом",
"invalidFile": "Выберите соответствующий исходный .nitro до 50 MB и повторите попытку.",
"resumeError": "Обновите историю и используйте последнюю попытку; проверьте файл и попробуйте снова.",
"mismatchedFile": "Этот файл относится к другой мебели.",
"alreadyStarted": "Восстановление уже началось. Обновите историю и используйте последнюю попытку.",
"notEligible": "Этот элемент нельзя восстановить вложением."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Kópia na obnovenie bude v tomto prehliadači uchovaná 7 dní.",
"saved": "Kópia na obnovenie bola v tomto prehliadači uložená na 7 dní.",
"unavailable": "Obnovenie v prehliadači nie je dostupné. Nechaj túto stránku otvorenú, kým sa zmeny neuložia.",
"found": "V tomto prehliadači je dostupná kópia na obnovenie, uložená",
"restore": "Obnoviť kópiu z prehliadača",
"discard": "Odstrániť kópiu z prehliadača",
"confirmRestore": "Nahradiť aktuálny formulár kópiou z prehliadača? Pred uložením skontroluj obsah.",
"confirmDiscard": "Odstrániť túto kópiu z prehliadača? Aktuálny formulár zostane nezmenený."
},
"nav": {
"home": "Domov",
"community": "Spoločenstva",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Diagnostika súborov",
"preliminary": "Predbežná kontrola adries; obsah sa overí pri importe.",
"http": "HTTP {status}",
"network": "Pripojenie zlyhalo",
"timeout": "Zdroj neodpovedal včas",
"invalidResponse": "Zdroj vrátil webovú stránku namiesto súboru",
"available": "Súbor nájdený; čaká na overenie",
"recover": "Priložiť originál a pokračovať",
"recoveryHint": "Vyberte {file} (do 50 MB). Overí sa jeho identita. Opakujú sa neúspešné položky; dokončené importy zostanú zachované.",
"attach": "Vyberte pôvodný súbor .nitro",
"working": "Overovanie alebo pokračovanie...",
"attached": "Zodpovedajúci súbor priložený",
"resume": "Obnoviť neúspešné importy s týmto súborom",
"invalidFile": "Vyberte zodpovedajúci pôvodný .nitro do 50 MB a skúste to znova.",
"resumeError": "Obnovte históriu a použite posledný pokus; skontrolujte súbor a skúste to znova.",
"mismatchedFile": "Tento súbor patrí inému nábytku.",
"alreadyStarted": "Obnova už začala. Obnovte históriu a použite posledný pokus.",
"notEligible": "Túto položku nemožno obnoviť prílohou."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Копија за опоравак чуваће се у овом прегледачу 7 дана.",
"saved": "Копија за опоравак сачувана је у овом прегледачу на 7 дана.",
"unavailable": "Опоравак у прегледачу није доступан. Остави ову страницу отвореном док се промене не сачувају.",
"found": "У овом прегледачу доступна је копија за опоравак, сачувана",
"restore": "Врати копију из прегледача",
"discard": "Избриши копију из прегледача",
"confirmRestore": "Заменити тренутни образац копијом из прегледача? Провери садржај пре чувања.",
"confirmDiscard": "Избрисати ову копију из прегледача? Тренутни образац остаће непромењен."
},
"nav": {
"home": "Хоме",
"community": "Заједница",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Дијагностика датотека",
"preliminary": "Прелиминарна провера адреса; садржај се проверава при увозу.",
"http": "HTTP {status}",
"network": "Повезивање није успело",
"timeout": "Извор није одговорио на време",
"invalidResponse": "Извор је вратио веб-страницу уместо датотеке",
"available": "Датотека пронађена; чека проверу",
"recover": "Приложи оригинал и настави",
"recoveryHint": "Изаберите {file} (до 50 MB). Проверава се идентитет. Понављају се неуспеле ставке; завршени увози се чувају.",
"attach": "Изаберите изворну .nitro датотеку",
"working": "Провера или наставак...",
"attached": "Одговарајућа датотека је приложена",
"resume": "Настави неуспеле увозе са датотеком",
"invalidFile": "Изаберите одговарајућу изворну .nitro датотеку до 50 MB и покушајте поново.",
"resumeError": "Освежите историју и користите последњи покушај; проверите датотеку и покушајте поново.",
"mismatchedFile": "Ова датотека припада другом комаду намештаја.",
"alreadyStarted": "Опоравак је већ покренут. Освежите историју и користите последњи покушај.",
"notEligible": "Ова ставка не подржава опоравак прилогом."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "En återställningskopia sparas i den här webbläsaren i 7 dagar.",
"saved": "En återställningskopia har sparats i den här webbläsaren i 7 dagar.",
"unavailable": "Återställning i webbläsaren är inte tillgänglig. Låt sidan vara öppen tills dina ändringar har sparats.",
"found": "En återställningskopia finns i den här webbläsaren, sparad",
"restore": "Återställ webbläsarkopia",
"discard": "Ta bort webbläsarkopia",
"confirmRestore": "Ersätta det aktuella formuläret med webbläsarens återställningskopia? Kontrollera innehållet innan du sparar.",
"confirmDiscard": "Ta bort den här återställningskopian från webbläsaren? Det aktuella formuläret förblir oförändrat."
},
"nav": {
"home": "Hem",
"community": "gemenskapen",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Fildiagnostik",
"preliminary": "Förhandskontroll av adresser; innehållet valideras vid import.",
"http": "HTTP {status}",
"network": "Anslutningen misslyckades",
"timeout": "Källan svarade inte i tid",
"invalidResponse": "Källan returnerade en webbsida i stället för en fil",
"available": "Fil hittad; väntar på validering",
"recover": "Bifoga originalet och fortsätt",
"recoveryHint": "Välj {file} (högst 50 MB). Identiteten kontrolleras. Misslyckade objekt försöks igen; slutförda importer bevaras.",
"attach": "Välj den ursprungliga .nitro-filen",
"working": "Validerar eller återupptar...",
"attached": "Matchande originalfil bifogad",
"resume": "Återuppta misslyckade importer med filen",
"invalidFile": "Välj en matchande originalfil i .nitro-format på högst 50 MB och försök igen.",
"resumeError": "Uppdatera historiken och använd senaste försöket; kontrollera filen och försök igen.",
"mismatchedFile": "Den här filen tillhör en annan möbel.",
"alreadyStarted": "Återställningen har redan startat. Uppdatera historiken och använd senaste försöket.",
"notEligible": "Det här objektet kan inte återställas med en bilaga."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Kurtarma kopyası bu tarayıcıda 7 gün saklanacak.",
"saved": "Kurtarma kopyası bu tarayıcıya 7 gün süreyle kaydedildi.",
"unavailable": "Tarayıcıda kurtarma kullanılamıyor. Değişikliklerin kaydedilene kadar bu sayfayı açık tut.",
"found": "Bu tarayıcıda bir kurtarma kopyası mevcut. Kaydedilme tarihi:",
"restore": "Tarayıcı kopyasını geri yükle",
"discard": "Tarayıcı kopyasını sil",
"confirmRestore": "Mevcut form tarayıcıdaki kurtarma kopyasıyla değiştirilsin mi? Kaydetmeden önce içeriği kontrol et.",
"confirmDiscard": "Bu kurtarma kopyası tarayıcıdan silinsin mi? Mevcut form değişmeden kalacak."
},
"nav": {
"home": "Ana sayfa",
"community": "Topluluk",
@@ -3784,6 +3794,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Dosya tanılaması",
"preliminary": "Adreslerin ön kontrolü; içerik içe aktarma sırasında doğrulanır.",
"http": "HTTP {status}",
"network": "Bağlantı başarısız",
"timeout": "Kaynak zamanında yanıt vermedi",
"invalidResponse": "Kaynak dosya yerine web sayfası döndürdü",
"available": "Dosya bulundu; doğrulama bekleniyor",
"recover": "Özgün dosyayı ekle ve sürdür",
"recoveryHint": "{file} dosyasını seçin (en fazla 50 MB). Kimliği doğrulanır. Başarısız öğeler yeniden denenir; tamamlanan aktarımlar korunur.",
"attach": "Özgün .nitro dosyasını seçin",
"working": "Doğrulanıyor veya sürdürülüyor...",
"attached": "Eşleşen özgün dosya eklendi",
"resume": "Başarısız aktarımları bu dosyayla sürdür",
"invalidFile": "En fazla 50 MB olan eşleşen özgün .nitro dosyasını seçip yeniden deneyin.",
"resumeError": "Geçmişi yenileyip son denemeyi kullanın; dosyayı kontrol edip yeniden deneyin.",
"mismatchedFile": "Bu dosya başka bir mobilyaya ait.",
"alreadyStarted": "Kurtarma zaten başladı. Geçmişi yenileyip son denemeyi kullanın.",
"notEligible": "Bu öğe ek dosyayla kurtarılamaz."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+30
View File
@@ -1,4 +1,14 @@
{
"draftRecovery": {
"ready": "Копія для відновлення зберігатиметься в цьому браузері 7 днів.",
"saved": "Копію для відновлення збережено в цьому браузері на 7 днів.",
"unavailable": "Відновлення в браузері недоступне. Не закривай цю сторінку, доки зміни не буде збережено.",
"found": "У цьому браузері доступна копія для відновлення. Дата збереження:",
"restore": "Відновити копію з браузера",
"discard": "Видалити копію з браузера",
"confirmRestore": "Замінити поточну форму копією з браузера? Перевір вміст перед збереженням.",
"confirmDiscard": "Видалити цю копію з браузера? Поточна форма залишиться без змін."
},
"nav": {
"home": "додому",
"community": "Спільнота",
@@ -3783,6 +3793,26 @@
},
"admin": {
"studio": {
"importDiagnostics": {
"attempts": "Діагностика файлів",
"preliminary": "Попередня перевірка адрес; вміст перевіряється під час імпорту.",
"http": "HTTP {status}",
"network": "Не вдалося підключитися",
"timeout": "Джерело не відповіло вчасно",
"invalidResponse": "Джерело повернуло вебсторінку замість файлу",
"available": "Файл знайдено; очікує перевірки",
"recover": "Прикріпити оригінал і продовжити",
"recoveryHint": "Виберіть {file} (до 50 MB). Його відповідність перевіряється. Повторюються невдалі елементи; завершені імпорти зберігаються.",
"attach": "Виберіть оригінальний файл .nitro",
"working": "Перевірка або відновлення...",
"attached": "Відповідний оригінал прикріплено",
"resume": "Повторити невдалі імпорти з цим файлом",
"invalidFile": "Виберіть відповідний оригінальний .nitro до 50 MB і повторіть спробу.",
"resumeError": "Оновіть історію й використайте останню спробу; перевірте файл і спробуйте знову.",
"mismatchedFile": "Цей файл належить іншому предмету меблів.",
"alreadyStarted": "Відновлення вже розпочато. Оновіть історію й використайте останню спробу.",
"notEligible": "Цей елемент не можна відновити вкладенням."
},
"completeness": {
"title": "Completeness and next steps",
"parts": {
+23
View File
@@ -0,0 +1,23 @@
import type { ImportJob } from "@/lib/furni/import-job";
export const furnitureJobFixture: ImportJob = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
userId: 42,
createdAt: "2030-01-01T12:00:00.000Z",
updatedAt: "2030-01-01T12:00:00.000Z",
state: "queued",
translate: false,
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "A chair",
type: "flooritem",
revision: 1,
category: "chair",
state: "pending",
},
],
};
export const furnitureCursorFixture = `${furnitureJobFixture.createdAt}|${furnitureJobFixture.id}`;